This commit is contained in:
2026-08-13 15:59:05 +08:00
parent 8b393d1906
commit 8bcd23f307
6 changed files with 115 additions and 22 deletions
+2 -2
View File
@@ -65,7 +65,7 @@ spring:
- id: warehouse-service - id: warehouse-service
uri: lb://warehouse-service uri: lb://warehouse-service
predicates: predicates:
- Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**,/api/platform/v1/partners/**,/api/platform/v1/express-companies/**,/api/platform/v1/pallets/**,/api/platform/v1/locations/**,/api/platform/v1/overseas-storage-fees/**,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees/** - Path=/api/merchant/v1/warehouses/**,/api/merchant/v1/partners/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**,/api/platform/v1/partners/**,/api/platform/v1/express-companies/**,/api/platform/v1/pallets/**,/api/platform/v1/locations/**,/api/platform/v1/overseas-storage-fees/**,/api/platform/v1/suppliers/**,/api/platform/v1/domestic-containers/**,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees/**
- id: file-service - id: file-service
uri: lb://file-service uri: lb://file-service
predicates: predicates:
@@ -77,7 +77,7 @@ spring:
- id: firstleg-service - id: firstleg-service
uri: lb://firstleg-service uri: lb://firstleg-service
predicates: predicates:
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
- id: finance-service - id: finance-service
uri: lb://finance-service uri: lb://finance-service
predicates: predicates:
@@ -0,0 +1,34 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.gateway.config.GatewayProperties;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(properties = {
"spring.cloud.nacos.discovery.enabled=false",
"spring.cloud.nacos.discovery.register-enabled=false",
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.gateway.discovery.locator.enabled=false",
"baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long"
})
class CustomerTransitStatisticsRouteContractTest {
@Autowired private GatewayProperties gatewayProperties;
@Test
void routesCustomerTransitSummaryToFirstlegService() {
var route = gatewayProperties.getRoutes().stream()
.filter(candidate -> "firstleg-service".equals(candidate.getId()))
.findFirst()
.orElseThrow();
assertThat(route.getUri().toString()).isEqualTo("lb://firstleg-service");
assertThat(route.getPredicates()).singleElement().satisfies(predicate -> {
assertThat(predicate.getName()).isEqualTo("Path");
assertThat(String.join(",", predicate.getArgs().values()))
.contains("/api/platform/v1/customers/{customerId}/container-transit-summary");
});
}
}
@@ -69,7 +69,9 @@ class GatewayFileRouteConfigurationTest {
assertThat(paths) assertThat(paths)
.contains("/api/platform/v1/pallets/**") .contains("/api/platform/v1/pallets/**")
.contains("/api/platform/v1/locations/**") .contains("/api/platform/v1/locations/**")
.contains("/api/platform/v1/overseas-storage-fees/**"); .contains("/api/platform/v1/overseas-storage-fees/**")
.contains("/api/platform/v1/suppliers/**")
.contains("/api/platform/v1/domestic-containers/**");
}); });
} }
@@ -13,27 +13,33 @@ export async function run(context) {
const orderPath = `/api/platform/v1/orders/first-leg/tenant-statistics?tenantId=${encodeURIComponent(customerId)}` const orderPath = `/api/platform/v1/orders/first-leg/tenant-statistics?tenantId=${encodeURIComponent(customerId)}`
const orderResponse = await context.request('GET', orderPath) const orderResponse = await context.request('GET', orderPath)
const orders = context.apiSuccess(MODULE, '查询累计与本月已提交主运单统计', orderResponse, `customerId=${customerId}`, OPTIONS) const orders = context.apiSuccess(MODULE, '查询累计与本月已提交主运单统计', orderResponse, `customerId=${customerId}`, OPTIONS)
context.assert(MODULE, '运单统计返回上海时区半开月份边界与客户隔离键', orderResponse, if (orders !== null) {
id(orders?.tenantId) === customerId && nonNegative(orders?.totalSubmittedOrders) context.assert(MODULE, '运单统计返回上海时区半开月份边界与客户隔离键', orderResponse,
&& nonNegative(orders?.currentMonthSubmittedOrders) && orders?.zoneId === 'Asia/Shanghai' id(orders?.tenantId) === customerId && nonNegative(orders?.totalSubmittedOrders)
&& Boolean(orders?.monthStart) && Boolean(orders?.nextMonthStart) && Boolean(orders?.asOf), && nonNegative(orders?.currentMonthSubmittedOrders) && orders?.zoneId === 'Asia/Shanghai'
`tenantId=${id(orders?.tenantId) || 'missing'}, zoneId=${orders?.zoneId || 'missing'}`, OPTIONS) && Boolean(orders?.monthStart) && Boolean(orders?.nextMonthStart) && Boolean(orders?.asOf),
`tenantId=${id(orders?.tenantId) || 'missing'}, zoneId=${orders?.zoneId || 'missing'}`, OPTIONS)
}
const financePath = `/api/platform/finance/v1/customer-accounts/${encodeURIComponent(customerId)}/summary` const financePath = `/api/platform/finance/v1/customer-accounts/${encodeURIComponent(customerId)}/summary`
const financeResponse = await context.request('GET', financePath) const financeResponse = await context.request('GET', financePath)
const finance = context.apiSuccess(MODULE, '查询非VOIDED正式结算应收汇总', financeResponse, `customerId=${customerId}`, OPTIONS) const finance = context.apiSuccess(MODULE, '查询非VOIDED正式结算应收汇总', financeResponse, `customerId=${customerId}`, OPTIONS)
context.assert(MODULE, '应收汇总保持CNY十进制金额与客户隔离键', financeResponse, if (finance !== null) {
id(finance?.customerId) === customerId && finance?.currency === 'CNY' context.assert(MODULE, '应收汇总保持CNY十进制金额与客户隔离键', financeResponse,
&& ['totalReceivable', 'receivedAmount', 'outstandingReceivable'].every((field) => nonNegative(finance?.[field])) id(finance?.customerId) === customerId && finance?.currency === 'CNY'
&& Number(finance?.outstandingReceivable) <= Number(finance?.totalReceivable), && ['totalReceivable', 'receivedAmount', 'outstandingReceivable'].every((field) => nonNegative(finance?.[field]))
`customerId=${id(finance?.customerId) || 'missing'}, currency=${finance?.currency || 'missing'}`, OPTIONS) && Number(finance?.outstandingReceivable) <= Number(finance?.totalReceivable),
`customerId=${id(finance?.customerId) || 'missing'}, currency=${finance?.currency || 'missing'}`, OPTIONS)
}
const transitPath = `/api/platform/v1/customers/${encodeURIComponent(customerId)}/container-transit-summary` const transitPath = `/api/platform/v1/customers/${encodeURIComponent(customerId)}/container-transit-summary`
const transitResponse = await context.request('GET', transitPath) const transitResponse = await context.request('GET', transitPath)
const transit = context.apiSuccess(MODULE, '查询已开船未到仓在途柜量', transitResponse, `customerId=${customerId}`, OPTIONS) const transit = context.apiSuccess(MODULE, '查询已开船未到仓在途柜量', transitResponse, `customerId=${customerId}`, OPTIONS)
context.assert(MODULE, '在途柜量保持TEU小数、未知柜型与客户隔离键', transitResponse, if (transit !== null) {
id(transit?.customerId) === customerId && nonNegative(transit?.containerCount) context.assert(MODULE, '在途柜量保持TEU小数、未知柜型与客户隔离键', transitResponse,
&& nonNegative(transit?.totalTeu) && nonNegative(transit?.unknownContainerCount) id(transit?.customerId) === customerId && nonNegative(transit?.containerCount)
&& Number(transit?.unknownContainerCount) <= Number(transit?.containerCount) && Boolean(transit?.definition), && nonNegative(transit?.totalTeu) && nonNegative(transit?.unknownContainerCount)
`customerId=${id(transit?.customerId) || 'missing'}, totalTeu=${transit?.totalTeu ?? 'missing'}, unknown=${transit?.unknownContainerCount ?? 'missing'}`, OPTIONS) && Number(transit?.unknownContainerCount) <= Number(transit?.containerCount) && Boolean(transit?.definition),
`customerId=${id(transit?.customerId) || 'missing'}, totalTeu=${transit?.totalTeu ?? 'missing'}, unknown=${transit?.unknownContainerCount ?? 'missing'}`, OPTIONS)
}
} }
@@ -0,0 +1,49 @@
function text(value) { return value == null ? '' : String(value).trim() }
export async function resolveOceanBookingMaster(context, module) {
const resources = context.createdResources || {}
const candidates = [resources.phase3Loading, resources.phase3ContainerLoading,
resources.orchestrationFixtures?.phase3Container, resources.inboundMeasurement,
resources.orders].filter(Boolean)
const source = candidates.find((item) => item.marker === context.marker) || candidates[0]
const tenantId = text(source?.tenantId || source?.customerId)
const inboundId = text(source?.inboundId)
let summary = source?.summary || null
if (inboundId && typeof context.request === 'function') {
const response = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
if (response?.body?.code === '200') summary = response.body.data?.summary || summary
}
const master = {
customerId: text(summary?.tenantId || tenantId),
customerName: text(summary?.customerName || source?.customerName),
channelCode: text(summary?.channelCode || source?.channelCode),
channelName: text(summary?.channelName || source?.channelName),
warehouseId: text(summary?.warehouseId || source?.warehouseId || source?.receivingWarehouseId),
warehouseName: text(summary?.warehouseName || source?.warehouseName),
}
const missing = ['customerId', 'customerName', 'channelCode', 'channelName', 'warehouseId', 'warehouseName']
.filter((key) => !master[key])
if (missing.length) {
context.addStep(module, '订舱 fixture 使用本轮正式客户/渠道/仓库主数据', null, false,
`marker=${context.marker}, inboundId=${inboundId || 'missing'}, missing=${missing.join(',')}`, { scenario: 'ocean-booking-master-data', deploymentBlocker: true })
return null
}
return master
}
export function oceanBookingDraft(context, suffix, master, labels = {}) {
return {
...master,
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`,
bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`,
vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V${suffix}`,
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
estimatedSailingAt: labels.estimatedSailingAt, estimatedArrivalAt: labels.estimatedArrivalAt,
remark: context.marker,
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
}
}
@@ -1,6 +1,7 @@
const MODULE = 'ocean-bookings' const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true } const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings' const BASE = '/api/platform/v1/ocean-bookings'
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) } function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) }
@@ -15,14 +16,15 @@ async function request(context, method, path, body, headers = {}) {
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } } } catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
} }
function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
function draft(context, suffix) { return { customerId: '10001', customerName: `订舱验收客户-${suffix}`, shippingCompanyId: null, shippingCompanyName: `船公司-${suffix}`, bookingAgentId: null, bookingAgentName: `订舱代理-${suffix}`, vesselName: `验收船-${suffix}`, voyageNo: `V${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', billOfLadingNo: `BL-${suffix}`, remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1 }] } } async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00' }) }, { 'Idempotency-Key': key }) }
async function createBooking(context, suffix, key) { return request(context, 'POST', BASE, { expectedVersion: 0, data: draft(context, suffix) }, { 'Idempotency-Key': key }) }
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
export async function run(context) { export async function run(context) {
const containerFlow = context.createdResources?.phase3Loading const containerFlow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading || context.createdResources?.phase3ContainerLoading
const suffix = context.marker.replace(/\D/g, '').slice(-10) const suffix = context.marker.replace(/\D/g, '').slice(-10)
const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
if (missing(list) || list.http === 0) { if (missing(list) || list.http === 0) {
step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`) step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`)
@@ -34,7 +36,7 @@ export async function run(context) {
for (const status of statuses) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); step(context, `订舱 ${status} 状态筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`) } for (const status of statuses) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); step(context, `订舱 ${status} 状态筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`) }
const key = `${context.marker}-ocean-create` const key = `${context.marker}-ocean-create`
const createResponse = await createBooking(context, suffix, key) const createResponse = await createBooking(context, suffix, key, master)
const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id) const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id)
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS) context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
context.assert(MODULE, '订舱 DTO 字段、DRAFT 和9类关联字段完整', createResponse, Boolean(bookingId) && detail?.oceanBooking?.status === 'DRAFT' && detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1, `bookingId=${bookingId || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS) context.assert(MODULE, '订舱 DTO 字段、DRAFT 和9类关联字段完整', createResponse, Boolean(bookingId) && detail?.oceanBooking?.status === 'DRAFT' && detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1, `bookingId=${bookingId || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS)
@@ -45,7 +47,7 @@ export async function run(context) {
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS) context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS)
const path = `${BASE}/${bookingId}` const path = `${BASE}/${bookingId}`
const initialVersion = Number(detail.oceanBooking.version) const initialVersion = Number(detail.oceanBooking.version)
const edited = { ...draft(context, suffix), vesselName: `编辑船-${suffix}`, voyageNo: `EV${suffix}` } const edited = { ...oceanBookingDraft(context, suffix, master, { estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00' }), vesselName: `编辑船-${suffix}`, voyageNo: `EV${suffix}` }
const update = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: edited }, { 'Idempotency-Key': `${context.marker}-ocean-edit` }) const update = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: edited }, { 'Idempotency-Key': `${context.marker}-ocean-edit` })
context.apiSuccess(MODULE, '编辑 DRAFT 订舱', update, `version=${initialVersion}`, OPTIONS) context.apiSuccess(MODULE, '编辑 DRAFT 订舱', update, `version=${initialVersion}`, OPTIONS)
const stale = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: { ...edited, vesselName: `STALE-${suffix}` } }, { 'Idempotency-Key': `${context.marker}-ocean-stale` }) const stale = await request(context, 'PUT', path, { expectedVersion: initialVersion, data: { ...edited, vesselName: `STALE-${suffix}` } }, { 'Idempotency-Key': `${context.marker}-ocean-stale` })