diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index f930399c2..793f7684c 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -33,14 +33,16 @@ | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | +| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | | SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | ## 修改约束 - 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 -- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 +- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 +- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING + INTERNATIONAL`,拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别、范围或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 ## 三期装柜与 PDA 资源结算 diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 0c2bcffaf..614d4c46b 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -55,6 +55,8 @@ paths: parameters: [{$ref: '#/components/parameters/CaseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] post: summary: Submit to customs agent and freeze approved file versions in a batch snapshot + description: customsAgentId must identify an ACTIVE Warehouse supplier in category CUSTOMS; the persisted agent name is resolved server-side and free text cannot bypass supplier validation. + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SubmitBatchRequest'}}}} responses: {'200': {description: Submission batch}} /api/platform/v1/customs/submission-batches/{batchId}/receipts: parameters: @@ -91,3 +93,13 @@ components: in: path required: true schema: {type: string, pattern: '^[1-9][0-9]*$'} + schemas: + SubmitBatchRequest: + type: object + required: [customsAgentId, expectedVersion] + properties: + customsAgentId: {type: integer, format: int64, minimum: 1, description: ACTIVE Warehouse supplier ID validated as CUSTOMS.} + customsAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse.} + externalReference: {type: string, maxLength: 128, nullable: true} + note: {type: string, maxLength: 500, nullable: true} + expectedVersion: {type: integer, minimum: 0} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index f965b590a..915db7ace 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -52,6 +52,8 @@ components: channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.} warehouseId: {type: integer, format: int64, minimum: 1} warehouseName: {type: string, maxLength: 128} + bookingAgentId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated as BOOKING with INTERNATIONAL scope.} + bookingAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot always comes from Warehouse and cannot be supplied without bookingAgentId.} serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.} freightTerm: {type: string, maxLength: 32, description: Required free-text Freight Term snapshot, for example NINGBO.} billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed bill of lading snapshot.} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 72d15f87e..12b16d666 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -160,6 +160,8 @@ components: oceanBookingId: {type: integer, format: int64, minimum: 1} containerBookingId: {type: integer, format: int64, minimum: 1} loadingPlanId: {type: integer, format: int64, minimum: 1} + carrierId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated as TRUCKING.} + carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse and cannot be supplied without carrierId.} plannedLoadingAt: {type: string, format: date-time} expectedVersion: {type: integer, minimum: 0} DetailStableReferences: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java new file mode 100644 index 000000000..3aaa1b6f7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseSupplierClientContractTest { + @Test + void usesWarehouseInternalSupplierEndpointAndToken() throws Exception { + FeignClient annotation = WarehouseSupplierClient.class.getAnnotation(FeignClient.class); + assertThat(annotation.path()).isEqualTo("/internal/warehouse/v1/suppliers"); + Method method = WarehouseSupplierClient.class.getDeclaredMethod("get", String.class, long.class, + boolean.class, String.class, String.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{supplierId}"); + assertThat(method.getParameterAnnotations()[0][0]).isInstanceOf(RequestHeader.class); + assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(method.getParameterAnnotations()[3][0]).isInstanceOf(RequestParam.class); + assertThat(method.getParameterAnnotations()[4][0]).isInstanceOf(RequestParam.class); + } + + @Test + void openApiDocumentsServerResolvedSupplierSnapshots() throws Exception { + String ocean = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"), StandardCharsets.UTF_8); + String truck = Files.readString(Path.of("src/main/resources/openapi/truck-appointments-v1.yaml"), StandardCharsets.UTF_8); + String customs = Files.readString(Path.of("src/main/resources/openapi/customs-v1.yaml"), StandardCharsets.UTF_8); + assertThat(ocean).contains("bookingAgentId:", "BOOKING", "INTERNATIONAL", "persisted snapshot always comes from Warehouse"); + assertThat(truck).contains("carrierId:", "TRUCKING", "persisted snapshot comes from Warehouse"); + assertThat(customs).contains("customsAgentId", "CUSTOMS", "free text cannot bypass supplier validation"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java index 5e090bdca..e4c1b851a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.customs.client.CustomsFileClient; import com.baiyue.firstleg.customs.dto.CustomsDtos.*; import com.baiyue.firstleg.customs.entity.*; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,6 +25,7 @@ import static org.mockito.Mockito.*; class CustomsServiceTest { @Mock CustomsRepository repository; @Mock CustomsFileClient files; + @Mock WarehouseSupplierSnapshotService suppliers; @InjectMocks CustomsService service; @BeforeEach @@ -31,6 +33,8 @@ class CustomsServiceTest { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "internalToken", "token"); + when(suppliers.customs(anyLong(), anyString())) + .thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", null, "ACTIVE")); when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1); } @@ -84,7 +88,7 @@ class CustomsServiceTest { when(repository.transition(41L, 4, "RECEIPT_PENDING", 7L, "admin")).thenReturn(1); CustomsSubmissionBatchEntity result = service.submitBatch(41L, 7L, "admin", "batch-1", - new SubmitBatchRequest("线下报关代理", "EXT-1", "提交", 4)); + new SubmitBatchRequest(88L, "线下报关代理", "EXT-1", "提交", 4)); assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE"); verify(repository).freeze(51L); 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 ca13a6894..5bd2b0ab0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -38,10 +38,13 @@ class TruckAppointmentServiceTest { @Mock OceanBookingRepository oceanBookings; @Mock SmartLoadingRepository loadingPlans; @Mock ContainerRepository containers; + @Mock WarehouseSupplierSnapshotService suppliers; @Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build(); @InjectMocks TruckAppointmentService service; - @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); + when(suppliers.trucking(any(), any())).thenReturn(null); + } @Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() { TruckAppointmentEntity appointment = new TruckAppointmentEntity(); diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs index 622a838d9..bf0c7d42e 100644 --- a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -101,3 +101,13 @@ test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer assert.match(containerLoading, /id\(candidate\?\.accountId\) === tenantId/) assert.match(containerLoading, /id\(item\.accountId\) === tenantId/) }) + +test('independent ocean booking release reuses the server-frozen transport snapshot', () => { + assert.match(bookingModule, /const releaseSnapshotResponse = await request\(context, 'GET', path\)/) + assert.match(bookingModule, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) + assert.match(bookingModule, /const frozenTransportValid = transportFields\.every/) + assert.match(bookingModule, /if \(!frozenTransportValid\) return/) + assert.match(bookingModule, /expectedVersion: version, \.\.\.frozenTransport, reason: context\.marker/) + assert.doesNotMatch(bookingModule, /expectedVersion: version, billOfLadingNo: `BL-\$\{suffix\}`/) + assert.doesNotMatch(bookingModule, /estimatedSailingAt: '2026-09-01T08:00:00'/) +}) diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index ba8042422..3df4837ea 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -68,10 +68,14 @@ async function releaseLoadingBooking(context, containerFlow, suffix) { allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { + const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, flow?.oceanBooking?.[field]])) + const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') + context.assert(MODULE, '实际装货柜次订舱回读完整冻结运输快照', flowResponse, frozenTransportValid, + `oceanBookingId=${flowOceanBookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) + if (!frozenTransportValid) return null const flowRelease = await request(context, 'POST', `${flowPath}/release`, { - expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`, - vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`, - estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', + expectedVersion: Number(flow.oceanBooking.version), ...frozenTransport, reason: context.marker, }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) @@ -140,6 +144,15 @@ export async function run(context) { const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` }) context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version) const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') + const releaseSnapshotResponse = await request(context, 'GET', path) + const releaseSnapshot = data(releaseSnapshotResponse) + const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, releaseSnapshot?.oceanBooking?.[field]])) + const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') + context.assert(MODULE, '改船后回读完整冻结运输快照供放舱', releaseSnapshot, frozenTransportValid + && transportFields.every((field) => String(frozenTransport[field]) === String(changeConfirmed?.oceanBooking?.[field])), + `bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) + if (!frozenTransportValid) return const allocatedContainerBookingId = '' if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') { context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, @@ -154,9 +167,7 @@ export async function run(context) { `allocations=${changeConfirmed?.containerAllocations?.length ?? 'n/a'}, version=${version}`, OPTIONS) } const released = await request(context, 'POST', `${path}/release`, { - expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`, - voyageNo: `RV${suffix}`, estimatedSailingAt: '2026-09-01T08:00:00', - estimatedArrivalAt: '2026-09-20T08:00:00', reason: 'release', + expectedVersion: version, ...frozenTransport, reason: context.marker, }, { 'Idempotency-Key': `${context.marker}-ocean-release` }) const releasedDetail = data(released) context.apiSuccess(MODULE, '放舱', released, `status=${releasedDetail?.oceanBooking?.status || 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index 7a8e52f99..f06d07a6a 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -21,8 +21,10 @@ export async function run(context) { // AWAITING_REVIEW is a PDA workflow state, not evidence permission. Evidence is // opened only after the server-side inbound aggregate has entered LOADING. + // The list endpoint only exposes tasks that are currently in the loading queue. + // Once PDA completion advances the task, use its stable task identity instead. const taskResponse = await context.request('GET', - `/api/platform/v1/first-leg/pda-loading/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) + `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(warehouseId)}`) const task = successful(taskResponse) ? taskResponse.body?.data : null const taskItem = task?.items?.find?.((item) => id(item?.inboundId) === inboundId) const taskIdentityValid = id(task?.id) === taskId diff --git a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs index 7af5e3886..071d4c070 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-stage-contract.test.mjs @@ -8,7 +8,8 @@ test('evidence gate requires stable PDA identity and server LOADING before any e for (const field of ['taskId', 'inboundId', 'loadingPlanId', 'containerBookingId', 'warehouseId']) { assert.match(source, new RegExp(`${field}`), `${field} must be part of the preflight identity`) } - assert.match(source, /pda-loading\/tasks\?loadingPlanId=/) + assert.match(source, /pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\?warehouseId=/) + assert.doesNotMatch(source, /pda-loading\/tasks\?loadingPlanId=/) assert.match(source, /task\?\.id\) === taskId/) assert.match(source, /task\?\.loadingPlanId\) === loadingPlanId/) assert.match(source, /task\?\.containerBookingId\) === containerBookingId/)