test(acceptance): cover booking agent carrier matrix

This commit is contained in:
2026-08-27 10:38:55 +08:00
parent 15b563610c
commit b4c46280de
3 changed files with 112 additions and 6 deletions
@@ -8,6 +8,28 @@ const oceanBookingFixture = readFileSync(new URL('./ocean-bookings.mjs', import.
const supplierOpenapi = readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml', import.meta.url), 'utf8') const supplierOpenapi = readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml', import.meta.url), 'utf8')
const firstlegSnapshot = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java', import.meta.url), 'utf8') const firstlegSnapshot = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java', import.meta.url), 'utf8')
const firstlegClient = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', import.meta.url), 'utf8') const firstlegClient = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', import.meta.url), 'utf8')
const supplierDtos = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java', import.meta.url), 'utf8')
const supplierService = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java', import.meta.url), 'utf8')
const supplierMapper = readFileSync(new URL('../../warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java', import.meta.url), 'utf8')
const supplierSchema = readFileSync(new URL('../../warehouse/src/main/resources/db/warehouse-supplier.sql', import.meta.url), 'utf8')
const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
const oceanBookingController = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java', import.meta.url), 'utf8')
const oceanBookingService = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java', import.meta.url), 'utf8')
const oceanBookingDtos = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', import.meta.url), 'utf8')
const oceanBookingOpenapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', import.meta.url), 'utf8')
const carrierScheduleService = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryService.java', import.meta.url), 'utf8')
const adminOceanPage = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url), 'utf8')
const adminOceanApi = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', import.meta.url), 'utf8')
const adminOceanTypes = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/types.ts', import.meta.url), 'utf8')
const adminBookingAgents = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/bookingAgents.ts', import.meta.url), 'utf8')
const CARRIER_CODES = ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE', 'OTHER']
const matrix = [
{ id: 'A', carrierCodes: ['MATSON', 'COSCO'] },
{ id: 'B', carrierCodes: ['MATSON'] },
{ id: 'C', carrierCodes: ['COSCO'] },
]
const candidates = (carrierCode) => matrix.filter((agent) => agent.carrierCodes.includes(carrierCode)).map((agent) => agent.id)
test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geographic scope gate', () => { test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geographic scope gate', () => {
assert.match(supplierFixture, /category:'BOOKING'/) assert.match(supplierFixture, /category:'BOOKING'/)
@@ -24,7 +46,7 @@ test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geogr
test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => { test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => {
assert.match(supplierOpenapi, /name: requireActive/) assert.match(supplierOpenapi, /name: requireActive/)
assert.match(supplierOpenapi, /name: category/) assert.match(supplierOpenapi, /name: category/)
assert.match(supplierOpenapi, /required: \[supplierId, name, category, status\]/) assert.match(supplierOpenapi, /required: \[supplierId, name, category, status, carrierCodes\]/)
assert.doesNotMatch(supplierOpenapi, /\bScope\b|name: scope|\bscope:/) assert.doesNotMatch(supplierOpenapi, /\bScope\b|name: scope|\bscope:/)
}) })
@@ -43,3 +65,82 @@ test('HTTP ocean booking acceptance selects an ACTIVE BOOKING supplier without a
assert.match(oceanBookingFixture, /使用 ACTIVE BOOKING 订舱代理事实源/) assert.match(oceanBookingFixture, /使用 ACTIVE BOOKING 订舱代理事实源/)
assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/) assert.doesNotMatch(oceanBookingFixture, /category=BOOKING[^'\n]*scope=/)
}) })
test('frozen MATSON and COSCO matrix is many-to-many and changing B to COSCO clears the selection', () => {
assert.deepEqual(candidates('MATSON'), ['A', 'B'])
assert.deepEqual(candidates('COSCO'), ['A', 'C'])
let selectedAgentId = 'B'
const nextCarrierCode = 'COSCO'
if (!candidates(nextCarrierCode).includes(selectedAgentId)) selectedAgentId = null
assert.equal(selectedAgentId, null)
})
test('supplier DTO, service and HTTP acceptance freeze normalized multi-carrier save and non-BOOKING rejection', () => {
assert.match(supplierDtos, /List<@Pattern\(regexp = "MATSON\|SML\|ZIM\|COSCO\|EMC\|HEDE\|OTHER"\) String> carrierCodes/)
assert.match(supplierService, /normalizeCarrierCodes\(request\.category\(\), request\.carrierCodes\(\)\)/)
assert.match(supplierService, /非BOOKING供应商禁止携带carrierCodes/)
assert.match(supplierService, /BOOKING供应商至少关联一个船司标准编码/)
assert.match(supplierService, /List\.copyOf\(normalized\)/)
assert.match(supplierFixture, /carrierCodes:\['MATSON','COSCO'\]/)
assert.match(supplierFixture, /非BOOKING携带carrierCodes返回A0400并保留链路ID/)
assert.match(supplierFixture, /code\(nonBookingCarrierResponse,'A0400'\)&&traced\(nonBookingCarrierResponse\)/)
})
test('normalized relation table and lookup index exist in module baseline and unified SQL', () => {
for (const sql of [supplierSchema, unifiedSql]) {
assert.match(sql, /CREATE TABLE IF NOT EXISTS wh_supplier_carrier/)
assert.match(sql, /PRIMARY KEY\s*\(?\s*supplier_id\s*,\s*carrier_code\s*\)?/i)
assert.match(sql, /idx_wh_supplier_carrier_code\s*\(?\s*tenant_id\s*,\s*carrier_code\s*,\s*supplier_id\s*\)?/i)
assert.match(sql, /carrier_code IN\s*\(?\s*'MATSON'\s*,\s*'SML'\s*,\s*'ZIM'\s*,\s*'COSCO'\s*,\s*'EMC'\s*,\s*'HEDE'\s*,\s*'OTHER'\s*\)?/i)
}
assert.match(supplierMapper, /FROM wh_supplier_carrier[\s\S]*tenant_id=#\{tenantId\} AND carrier_code=#\{carrierCode\}/)
})
test('carrier-filtered booking-agent endpoint returns only ACTIVE BOOKING rows linked to the selected code', () => {
assert.match(oceanBookingController, /@GetMapping\("\/booking-agents"\)[\s\S]*@RequestParam String carrierCode/)
assert.match(firstlegClient, /candidates\([\s\S]*@RequestParam String category,[\s\S]*@RequestParam String carrierCode/)
assert.match(firstlegSnapshot, /client\.candidates\(internalToken, "BOOKING", carrierCode\)/)
assert.match(oceanBookingService, /bookingAgents\(String carrierCode\)[\s\S]*"ACTIVE"\.equals\(value\.status\(\)\)[\s\S]*"BOOKING"\.equals\(value\.category\(\)\)[\s\S]*value\.carrierCodes\(\)\.contains\(normalized\)/)
assert.match(oceanBookingDtos, /record BookingAgentOption\(Long supplierId, String name, List<String> carrierCodes\)/)
assert.match(oceanBookingOpenapi, /\/api\/platform\/v1\/ocean-bookings\/booking-agents:/)
assert.match(oceanBookingOpenapi, /name: carrierCode[\s\S]*enum: \[MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER\]/)
})
test('Admin loads candidates by carrier and clears an invalid selected agent when the carrier changes', () => {
assert.match(adminOceanApi, /listOceanBookingAgents/)
assert.match(adminOceanApi, /\$\{basePath\}\/booking-agents/)
assert.match(adminOceanTypes, /carrierCodes:\s*OceanBookingCarrierCode\[\]/)
assert.match(adminOceanPage, /listOceanBookingAgents\(carrierCode\)/)
assert.match(adminOceanPage, /bookingAgentId\s*=\s*null/)
assert.match(adminOceanPage, /shouldClearBookingAgent\(form\.data\.bookingAgentId, options\)/)
assert.match(adminBookingAgents, /!options\.some\(\(option\) => option\.supplierId === String\(currentSupplierId\)\)/)
})
test('bypassed unlinked agent is A0400 with trace and both DRAFT save and RELEASE revalidate the matrix', () => {
assert.match(oceanBookingService, /resolveDraft\(request\.data\(\)\)/)
assert.match(oceanBookingService, /resolveBookingAgent\(d\.bookingAgentId\(\), d\.bookingAgentName\(\), carrier == null \? null : carrier\.carrierCode\(\)\)/)
assert.match(oceanBookingService, /validateMasterData\(before\)/)
assert.match(oceanBookingService, /resolveBookingAgent\(booking\.getBookingAgentId\(\), booking\.getBookingAgentName\(\), booking\.getCarrierCode\(\)\)/)
assert.match(oceanBookingService, /throw badRequest\("所选订舱代理未关联当前船公司"\)/)
const bypass = { http: 200, body: { code: 'A0400', message: '所选订舱代理未关联当前船公司', requestId: 'request-matrix', traceId: 'trace-matrix' } }
assert.equal(bypass.http, 200)
assert.equal(bypass.body.code, 'A0400')
assert.ok(bypass.body.requestId && bypass.body.traceId)
})
test('CARRIER master data keeps the fixed enum while shipping-node tasks consume carrierCode only', () => {
assert.deepEqual(CARRIER_CODES, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE', 'OTHER'])
assert.match(oceanBookingService, /CARRIER_CODES = Set\.of\("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"\)/)
assert.match(carrierScheduleService, /setCarrierCode\(strategy\.carrierCode\(\)\)/)
assert.doesNotMatch(carrierScheduleService, /carrierCodes\(\)|bookingAgentId|bookingAgentName|wh_supplier_carrier/)
})
test('logistics channel contract remains independent from carrier-agent relationships', () => {
const channelMethod = oceanBookingService.slice(oceanBookingService.indexOf('public List<ChannelOption> channels'), oceanBookingService.indexOf('public Detail detail'))
const channelDto = oceanBookingDtos.slice(oceanBookingDtos.indexOf('record ChannelOption'), oceanBookingDtos.indexOf('record BookingAgentOption'))
assert.match(channelMethod, /priceBooks\.channels/)
assert.match(channelMethod, /channelOption/)
assert.doesNotMatch(channelMethod, /carrierCodes|bookingAgent|shippingCompany|supplier/)
assert.doesNotMatch(channelDto, /carrierCodes|bookingAgent|shippingCompany|supplier/)
assert.match(oceanBookingOpenapi, /channelCode:/)
})
@@ -5,8 +5,10 @@ const source=readFileSync(new URL('./suppliers.mjs',import.meta.url),'utf8')
const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8') const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',import.meta.url),'utf8')
const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8') const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8')
test('supplier acceptance covers full CRUD-like lifecycle and boundary failures',()=>{ test('supplier acceptance covers full CRUD-like lifecycle, carrier matrix and boundary failures',()=>{
for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','非BOOKING携带carrierCodes返回A0400并保留链路ID','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker))
assert.match(source,/carrierCodes:\['MATSON','COSCO'\]/)
assert.match(source,/JSON\.stringify\(detail\?\.carrierCodes\)===JSON\.stringify\(\['MATSON','COSCO'\]\)/)
assert.doesNotMatch(source,/scope\s*:/) assert.doesNotMatch(source,/scope\s*:/)
}) })
test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s))
+6 -3
View File
@@ -10,7 +10,7 @@ function records(page) { return Array.isArray(page?.records) ? page.records : []
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function traced(response) { return Boolean(response?.requestId && response?.traceId) } function traced(response) { return Boolean(response?.requestId && response?.traceId) }
function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` } function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` }
function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } } function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',carrierCodes:['MATSON','COSCO'],region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } }
export async function run(context) { export async function run(context) {
const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20) const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20)
@@ -31,7 +31,7 @@ export async function run(context) {
try { try {
const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS) const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS)
context.assert(MODULE,'详情字段与平台作用域正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&!Object.hasOwn(detail,'scope'),`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}, scopeExposed=${Object.hasOwn(detail||{},'scope')}`,OPTIONS) context.assert(MODULE,'详情字段与平台作用域及多船司关联正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&!Object.hasOwn(detail,'scope')&&JSON.stringify(detail?.carrierCodes)===JSON.stringify(['MATSON','COSCO']),`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}, carrierCodes=${JSON.stringify(detail?.carrierCodes)}, scopeExposed=${Object.hasOwn(detail||{},'scope')}`,OPTIONS)
const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name})) const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name}))
context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS) context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS)
@@ -39,6 +39,9 @@ export async function run(context) {
const tenantResponse=await context.request('GET',query({tenantId:1,page:1,size:20})) const tenantResponse=await context.request('GET',query({tenantId:1,page:1,size:20}))
context.assert(MODULE,'tenantId非0被拒绝',tenantResponse,code(tenantResponse,'A0400')&&traced(tenantResponse),`code=${tenantResponse.body?.code}, requestId=${tenantResponse.requestId||'missing'}, traceId=${tenantResponse.traceId||'missing'}`,OPTIONS) context.assert(MODULE,'tenantId非0被拒绝',tenantResponse,code(tenantResponse,'A0400')&&traced(tenantResponse),`code=${tenantResponse.body?.code}, requestId=${tenantResponse.requestId||'missing'}, traceId=${tenantResponse.traceId||'missing'}`,OPTIONS)
const nonBookingCarrierResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}NB`,{name:`非订舱供应商非法船司-${marker}`,category:'CUSTOMS',carrierCodes:['MATSON']}))
context.assert(MODULE,'非BOOKING携带carrierCodes返回A0400并保留链路ID',nonBookingCarrierResponse,code(nonBookingCarrierResponse,'A0400')&&traced(nonBookingCarrierResponse),`code=${nonBookingCarrierResponse.body?.code}, requestId=${nonBookingCarrierResponse.requestId||'missing'}, traceId=${nonBookingCarrierResponse.traceId||'missing'}`,OPTIONS)
const merchantLogin=await context.request('POST','/api/auth/login',{username:MERCHANT_USERNAME,password:MERCHANT_PASSWORD},'') const merchantLogin=await context.request('POST','/api/auth/login',{username:MERCHANT_USERNAME,password:MERCHANT_PASSWORD},'')
const merchantToken=ok(merchantLogin)?merchantLogin.body?.data?.accessToken:'' const merchantToken=ok(merchantLogin)?merchantLogin.body?.data?.accessToken:''
const forbidden=merchantToken?await context.request('GET',query({tenantId:0,page:1,size:20}),undefined,merchantToken):merchantLogin const forbidden=merchantToken?await context.request('GET',query({tenantId:0,page:1,size:20}),undefined,merchantToken):merchantLogin
@@ -49,7 +52,7 @@ export async function run(context) {
context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS) context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS)
const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS) const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS)
context.assert(MODULE,'编辑字段已持久化',editedResponse,edited?.name===updateBody.name&&!Object.hasOwn(edited,'scope')&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45',`name=${edited?.name||'missing'}, scopeExposed=${Object.hasOwn(edited||{},'scope')}`,OPTIONS) context.assert(MODULE,'编辑字段及多船司关联已持久化',editedResponse,edited?.name===updateBody.name&&!Object.hasOwn(edited,'scope')&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45'&&JSON.stringify(edited?.carrierCodes)===JSON.stringify(['MATSON','COSCO']),`name=${edited?.name||'missing'}, carrierCodes=${JSON.stringify(edited?.carrierCodes)}, scopeExposed=${Object.hasOwn(edited||{},'scope')}`,OPTIONS)
const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'}) const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'})
context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse) context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse)