diff --git a/file/AGENTS.md b/file/AGENTS.md index b6dabc818..75251eada 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -17,6 +17,7 @@ | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 | | 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | +| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index e10b1b043..9826e2fb5 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -8,6 +8,7 @@ import java.util.Arrays; /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ enum BusinessFileCategory { TRUCK_APPOINTMENT_ATTACHMENT(false, false), + OCEAN_BOOKING_DOCUMENT(true, false), CUSTOMS_DOCUMENT(false, false), CUSTOMS_RECEIPT(false, false), FINANCE_VOUCHER(false, false), diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index bd8499dd5..779a7acde 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -93,11 +93,12 @@ public class InternalBusinessFileService { } private static Metadata metadata(FileObjectEntity value) { - return new Metadata(value.getId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(), + return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER", + value.getTenantId(), value.getBusinessType(), value.getBusinessId(), value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes()); } - public record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {} public record Download(String fileName, String contentType, byte[] content) {} } diff --git a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml index c01c5e481..0cdaa4e73 100644 --- a/file/src/main/resources/openapi/customs-file-and-export-v1.yaml +++ b/file/src/main/resources/openapi/customs-file-and-export-v1.yaml @@ -107,9 +107,10 @@ components: data: {$ref: '#/components/schemas/BusinessFileMetadata'} BusinessFileMetadata: type: object - required: [fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] properties: fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输} + ownerScope: {type: string, enum: [CUSTOMER]} tenantId: {type: string, description: CUSTOMER owner scope客户ID} businessType: {type: string, description: fileCategory} businessId: {type: string, description: loadingPlanId} diff --git a/file/src/main/resources/openapi/ocean-booking-file-v1.yaml b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml new file mode 100644 index 000000000..a58af9548 --- /dev/null +++ b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml @@ -0,0 +1,112 @@ +openapi: 3.0.3 +info: + title: Ocean Booking Document Internal API + version: 1.0.0 + description: >- + 订舱文件归属订舱聚合,不归属拖车。固定ownerScope=CUSTOMER、tenantId=订舱customerId、 + businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与 + BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。 + x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING] +paths: + /internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}: + post: + summary: Firstleg上传订舱附件 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/OperatorId'} + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/OceanBookingId'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: TXT/PDF/JPG/PNG,最大10MiB + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}: + get: + summary: Firstleg校验订舱附件元数据 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/OceanBookingId'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': {$ref: '#/components/responses/Metadata'} + /internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}/{fileId}/content: + get: + summary: Firstleg下载订舱附件 + security: [{internalServiceToken: []}] + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/OceanBookingId'} + - {$ref: '#/components/parameters/FileId'} + responses: + '200': + description: 附件内容;错误订舱ID、拖车业务类型或文件ID不得命中 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + content: + application/octet-stream: {schema: {type: string, format: binary}} +components: + securitySchemes: + internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + OperatorId: + name: X-Operator-Id + in: header + required: true + schema: {$ref: '#/components/schemas/Id'} + TenantId: + name: tenantId + in: path + required: true + description: 订舱customerId + schema: {$ref: '#/components/schemas/Id'} + OceanBookingId: + name: oceanBookingId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + responses: + Metadata: + description: 精确订舱归属元数据 + content: + application/json: + schema: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + data: {$ref: '#/components/schemas/Metadata'} + schemas: + Id: {type: string, pattern: '^[1-9][0-9]*$'} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes] + properties: + fileId: {$ref: '#/components/schemas/Id'} + ownerScope: {type: string, enum: [CUSTOMER]} + tenantId: + allOf: [{$ref: '#/components/schemas/Id'}] + description: 订舱customerId + businessType: {type: string, enum: [OCEAN_BOOKING_DOCUMENT]} + businessId: + allOf: [{$ref: '#/components/schemas/Id'}] + description: oceanBookingId + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [text/plain, application/pdf, image/jpeg, image/png]} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} diff --git a/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java new file mode 100644 index 000000000..215f783ba --- /dev/null +++ b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.file.config; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OceanBookingFileContractTest { + @Test + void freezesInternalCustomerOwnedBookingDocumentContract() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-booking-file-v1.yaml")); + + assertThat(yaml) + .contains("/internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}:") + .contains("ownerScope: {type: string, enum: [CUSTOMER]}") + .contains("description: 订舱customerId") + .contains("description: oceanBookingId") + .contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]") + .contains("name: X-Internal-Service-Token", "Cache-Control") + .doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/"); + } +} diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 799ee0857..7e6c38380 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -23,7 +23,7 @@ class InternalBusinessFileControllerTest { @Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() { var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes()); - var metadata = new InternalBusinessFileService.Metadata(81L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", + var metadata = new InternalBusinessFileService.Metadata(81L, "PLATFORM", 0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", "clearance.pdf", "hash", "application/pdf", file.getSize()); when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata); when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata); diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 82a1e3822..b173cd3a5 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -69,6 +69,44 @@ class InternalBusinessFileServiceTest { verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false); } + @Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", + "%PDF-1.4".getBytes()); + when(storage.store(10001L, 61L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking.pdf", + "application/pdf", file.getSize(), "f".repeat(64), "booking.pdf")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(111L); return 1; + }); + + var metadata = service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file); + + assertThat(metadata).extracting(InternalBusinessFileService.Metadata::ownerScope, + InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly("CUSTOMER", 10001L, "OCEAN_BOOKING_DOCUMENT", "61"); + assertThatThrownBy(() -> service.upload(0L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)) + .isInstanceOf(BizException.class).hasMessageContaining("明确客户"); + } + + @Test void oceanBookingDocumentRejectsWrongCustomerBookingAndTruckOwnership() { + FileObjectEntity value = new FileObjectEntity(); value.setId(111L); value.setTenantId(10001L); + value.setBusinessType("OCEAN_BOOKING_DOCUMENT"); value.setBusinessId("61"); + value.setOriginalName("booking.pdf"); value.setObjectKey("business-files/10001/61/booking.pdf"); + value.setContentType("application/pdf"); value.setSha256("f".repeat(64)); value.setSizeBytes(8L); + when(files.selectOne(any())).thenReturn(value, null, null, null); + + assertThat(service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 111L).ownerScope()) + .isEqualTo("CUSTOMER"); + assertThatThrownBy(() -> service.metadata(10002L, "OCEAN_BOOKING_DOCUMENT", "61", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "OCEAN_BOOKING_DOCUMENT", "62", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.metadata(10001L, "TRUCK_APPOINTMENT_ATTACHMENT", "61", 111L)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()); assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)) diff --git a/print/AGENTS.md b/print/AGENTS.md index efe020bb0..85608b755 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -28,7 +28,7 @@ - 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。 - 其他模块接入时先读 `README.md` 和 OpenAPI,在调用模块内声明 DTO 并从既有业务数据自动映射;不得依赖 Print 的 Java DTO,不得新增要求用户重复填写打印字段的表单。 -- A4 V1 每个 `document` 的 `cargoRows` 只允许 0 或 1 项;当前渲染器只消费首行,调用方不得发送会被静默忽略的后续行。 +- A4 V1 每个 `document` 的 `cargoRows` 固定恰好 1 项且六个货物字段均非空;两个模板分别使用冻结的允许键与必填键,未知键、缺字段或额外货物行必须返回 `A0422`,不得静默忽略。 - 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。 - 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。 - 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。 diff --git a/print/README.md b/print/README.md index b240f8704..3fec0f4a3 100644 --- a/print/README.md +++ b/print/README.md @@ -112,16 +112,20 @@ X-Request-Id: | `pageSpec.heightMm` | 是 | 固定数值 `297` | | `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 | | `documents` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 | -| `documents[].fields` | 否 | 字符串字典,字段值允许换行,单值最多 4000 字符 | -| `documents[].cargoRows` | 否 | 0-1 项;当前 V1 模板只渲染一行货物,调用方不得发送第二行 | +| `documents[].fields` | 是 | 只能使用所选模板登记的键;未知键按 `A0422` 拒绝,字段值允许换行,单值最多 4000 字符 | +| `documents[].cargoRows` | 是 | 恰好 1 项;六个货物字段均必填,空值或第二行按 `A0422` 拒绝 | 托运书模板支持的完整 `fields` 键:`toCompany`、`issuerNameCn`、`issuerNameEn`、`issuerAddress`、`issuerPostalCode`、`issuerTel`、`issuerFax`、`shipper`、`consignee`、`notifyParty`、`picName`、`picTel`、`picFax`、`documentName`、`documentTel`、`documentFax`、`jobNumber`、`etdDate`、`serviceTerm`、`freightTerm`、`apCode`、`apName`、`cargoQuantity`、`transshipment`、`partialShipment`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`totalPackagesInWords`、`remark`、`carrier`、`bookingAgent`、`printDate`。 +托运书必填键固定为:`toCompany`、`shipper`、`consignee`、`notifyParty`、`jobNumber`、`cargoQuantity`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`totalPackagesInWords`;其余登记键可选。 + 提单确认件模板支持的完整 `fields` 键:`shipper`、`consignee`、`notifyParty`、`bookingNumber`、`ensAmsCutoffDate`、`customsCutoffDate`、`expectedDepartureDate`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`finalDestination`、`serviceTerm`、`freightTerm`、`shipperLoadCountSeal`、`containerSpec`、`shippedOnBoard`、`draftText`、`totalPackagesInWords`、`containerSummary`、`notes`、`issuePlace`、`issueDate`。 -调用方 MUST 只发送所选模板支持的键。当前服务会忽略未知键而不报错,因此调用方必须通过单元测试固定字段映射,避免拼写错误生成缺字段 PDF。空值字段可以省略;不得为了填满模板要求用户重复录入订单、客户、航线或货物中已经存在的数据。 +提单确认件必填键固定为:`shipper`、`consignee`、`notifyParty`、`bookingNumber`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`serviceTerm`、`freightTerm`、`totalPackagesInWords`;其余登记键可选。 -未传 `downloadFileName` 时,服务使用 `freight-a4-documents-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。 +调用方 MUST 只发送所选模板支持的键;Print 会拒绝未知键、缺少必填键、空白必填值及不完整货物行,避免拼写错误生成缺字段 PDF。可选空值可以省略;不得为了填满模板要求用户重复录入订单、客户、航线或货物中已经存在的数据。 + +未传 `downloadFileName` 时,托运书使用 `{jobNumber}-出口货物托运单.pdf`,提单确认件使用 `{bookingNumber}-提单确认件.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB,成功 `Content-Type` 固定为 `application/pdf`。 ## 3. 响应处理 diff --git a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java index 17fdf30e0..1aff9298d 100644 --- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java +++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java @@ -68,13 +68,7 @@ public class InternalPdfController { @Valid @RequestBody FreightA4PdfDtos.Request request, HttpServletRequest servletRequest) { tokenGuard.verify(internalToken); - String requestId = CorrelationContext.requestId(servletRequest); - FreightA4PdfDtos.Request effectiveRequest = request; - if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) { - effectiveRequest = new FreightA4PdfDtos.Request(request.templateCode(), request.pageSpec(), - "freight-a4-documents-" + requestId + ".pdf", request.documents()); - } - FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(effectiveRequest); + FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(request); byte[] content = pdf.content(); ContentDisposition disposition = ContentDisposition.attachment() diff --git a/print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java b/print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java index 53da17c5f..118f86444 100644 --- a/print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java +++ b/print/src/main/java/com/baiyue/print/service/FreightA4PdfService.java @@ -27,7 +27,25 @@ public class FreightA4PdfService { private static final int MAX_FILE_NAME_CODE_POINTS = 120; private static final int MAX_FIELD_KEY_CODE_POINTS = 80; private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000; - private static final int MAX_CARGO_ROWS = 50; + private static final Set SHIPPING_FIELDS = Set.of( + "toCompany", "issuerNameCn", "issuerNameEn", "issuerAddress", "issuerPostalCode", + "issuerTel", "issuerFax", "shipper", "consignee", "notifyParty", "picName", "picTel", + "picFax", "documentName", "documentTel", "documentFax", "jobNumber", "etdDate", + "serviceTerm", "freightTerm", "apCode", "apName", "cargoQuantity", "transshipment", + "partialShipment", "oceanVesselVoyage", "portOfLoading", "portOfDischarge", + "placeOfDelivery", "totalPackagesInWords", "remark", "carrier", "bookingAgent", "printDate"); + private static final Set SHIPPING_REQUIRED_FIELDS = Set.of( + "toCompany", "shipper", "consignee", "notifyParty", "jobNumber", "cargoQuantity", + "portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords"); + private static final Set BILL_FIELDS = Set.of( + "shipper", "consignee", "notifyParty", "bookingNumber", "ensAmsCutoffDate", + "customsCutoffDate", "expectedDepartureDate", "oceanVesselVoyage", "portOfLoading", + "portOfDischarge", "placeOfDelivery", "finalDestination", "serviceTerm", "freightTerm", + "shipperLoadCountSeal", "containerSpec", "shippedOnBoard", "draftText", + "totalPackagesInWords", "containerSummary", "notes", "issuePlace", "issueDate"); + private static final Set BILL_REQUIRED_FIELDS = Set.of( + "shipper", "consignee", "notifyParty", "bookingNumber", "portOfLoading", + "portOfDischarge", "placeOfDelivery", "serviceTerm", "freightTerm", "totalPackagesInWords"); private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210"); private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297"); private static final Set SUPPORTED_TEMPLATES = Set.of( @@ -99,35 +117,45 @@ public class FreightA4PdfService { List documents = new ArrayList<>(request.documents().size()); for (int index = 0; index < request.documents().size(); index++) { - documents.add(normalizeDocument(request.documents().get(index), index)); + documents.add(normalizeDocument(templateCode, request.documents().get(index), index)); } - return new NormalizedRequest(templateCode, normalizeFileName(request.downloadFileName()), + return new NormalizedRequest(templateCode, + normalizeFileName(request.downloadFileName(), templateCode, documents.get(0)), List.copyOf(documents)); } - private FreightA4PdfDtos.Document normalizeDocument(FreightA4PdfDtos.Document document, int index) { + private FreightA4PdfDtos.Document normalizeDocument(String templateCode, + FreightA4PdfDtos.Document document, int index) { if (document == null) { invalid("documents[" + index + "] is required"); } Map fields = new LinkedHashMap<>(); if (document.fields() != null) { + Set allowed = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode) + ? SHIPPING_FIELDS : BILL_FIELDS; for (Map.Entry entry : document.fields().entrySet()) { String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key")); if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) { invalid(field(index, "fields." + key) + " key is too long"); } + if (!allowed.contains(key)) { + invalid(field(index, "fields." + key) + " is not supported by " + templateCode); + } fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key))); } } - List rows = new ArrayList<>(); - if (document.cargoRows() != null) { - if (document.cargoRows().size() > MAX_CARGO_ROWS) { - invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items"); - } - for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) { - rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex)); + Set required = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode) + ? SHIPPING_REQUIRED_FIELDS : BILL_REQUIRED_FIELDS; + for (String key : required) { + if (!fields.containsKey(key) || fields.get(key).isBlank()) { + invalid(field(index, "fields." + key) + " is required by " + templateCode); } } + List rows = new ArrayList<>(); + if (document.cargoRows() == null || document.cargoRows().size() != 1) { + invalid(field(index, "cargoRows") + " must contain exactly one item"); + } + rows.add(normalizeCargoRow(document.cargoRows().get(0), index, 0)); return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows)); } @@ -138,19 +166,22 @@ public class FreightA4PdfService { } String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "]."; return new FreightA4PdfDtos.CargoRow( - normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"), - normalizeOptionalBlock(row.quantity(), prefix + "quantity"), - normalizeOptionalBlock(row.packaging(), prefix + "packaging"), - normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"), - normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"), - normalizeOptionalBlock(row.measurement(), prefix + "measurement")); + normalizeRequiredBlock(row.marksAndNos(), prefix + "marksAndNos"), + normalizeRequiredBlock(row.quantity(), prefix + "quantity"), + normalizeRequiredBlock(row.packaging(), prefix + "packaging"), + normalizeRequiredBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"), + normalizeRequiredBlock(row.grossWeight(), prefix + "grossWeight"), + normalizeRequiredBlock(row.measurement(), prefix + "measurement")); } - private String normalizeFileName(String value) { - if (value == null) { - return "freight-a4-documents.pdf"; + private String normalizeFileName(String value, String templateCode, FreightA4PdfDtos.Document firstDocument) { + String candidate = value; + if (candidate == null) { + candidate = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode) + ? firstDocument.field("jobNumber") + "-出口货物托运单.pdf" + : firstDocument.field("bookingNumber") + "-提单确认件.pdf"; } - String normalized = normalizeRequiredLine(value, "downloadFileName"); + String normalized = normalizeRequiredLine(candidate, "downloadFileName"); if (normalized.codePointCount(0, normalized.length()) > MAX_FILE_NAME_CODE_POINTS) { invalid("downloadFileName must not exceed 120 characters"); } @@ -165,6 +196,12 @@ public class FreightA4PdfService { return normalized; } + private String normalizeRequiredBlock(String value, String field) { + String normalized = normalizeOptionalBlock(value, field); + if (normalized.isBlank()) invalid(field + " must not be blank"); + return normalized; + } + private String normalizeRequiredLine(String value, String field) { String normalized = normalizeOptionalBlock(value, field); if (normalized.isBlank()) { diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index 30b6c5e83..d2ab7d478 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -173,6 +173,7 @@ components: type: string maxLength: 120 example: BYSE2607044-托运书.pdf + description: 未传时托运书为{jobNumber}-出口货物托运单.pdf,提单确认件为{bookingNumber}-提单确认件.pdf documents: type: array minItems: 1 @@ -192,21 +193,77 @@ components: enum: [297] FreightA4Document: type: object + required: [fields, cargoRows] + x-template-contracts: + SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1: + required: [toCompany, shipper, consignee, notifyParty, jobNumber, cargoQuantity, portOfLoading, portOfDischarge, placeOfDelivery, totalPackagesInWords] + optional: [issuerNameCn, issuerNameEn, issuerAddress, issuerPostalCode, issuerTel, issuerFax, picName, picTel, picFax, documentName, documentTel, documentFax, etdDate, serviceTerm, freightTerm, apCode, apName, transshipment, partialShipment, oceanVesselVoyage, remark, carrier, bookingAgent, printDate] + BILL_OF_LADING_CONFIRMATION_A4_V1: + required: [shipper, consignee, notifyParty, bookingNumber, portOfLoading, portOfDischarge, placeOfDelivery, serviceTerm, freightTerm, totalPackagesInWords] + optional: [ensAmsCutoffDate, customsCutoffDate, expectedDepartureDate, oceanVesselVoyage, finalDestination, shipperLoadCountSeal, containerSpec, shippedOnBoard, draftText, containerSummary, notes, issuePlace, issueDate] properties: fields: type: object - additionalProperties: - type: string - maxLength: 4000 - description: Template field map. Known keys are documented in the print module README. + additionalProperties: false + description: 只能提交所选模板登记的键;未知键和缺少必填键返回A0422。 + properties: + toCompany: {$ref: '#/components/schemas/FreightField'} + issuerNameCn: {$ref: '#/components/schemas/FreightField'} + issuerNameEn: {$ref: '#/components/schemas/FreightField'} + issuerAddress: {$ref: '#/components/schemas/FreightField'} + issuerPostalCode: {$ref: '#/components/schemas/FreightField'} + issuerTel: {$ref: '#/components/schemas/FreightField'} + issuerFax: {$ref: '#/components/schemas/FreightField'} + shipper: {$ref: '#/components/schemas/FreightField'} + consignee: {$ref: '#/components/schemas/FreightField'} + notifyParty: {$ref: '#/components/schemas/FreightField'} + picName: {$ref: '#/components/schemas/FreightField'} + picTel: {$ref: '#/components/schemas/FreightField'} + picFax: {$ref: '#/components/schemas/FreightField'} + documentName: {$ref: '#/components/schemas/FreightField'} + documentTel: {$ref: '#/components/schemas/FreightField'} + documentFax: {$ref: '#/components/schemas/FreightField'} + jobNumber: {$ref: '#/components/schemas/FreightField'} + etdDate: {$ref: '#/components/schemas/FreightField'} + serviceTerm: {$ref: '#/components/schemas/FreightField'} + freightTerm: {$ref: '#/components/schemas/FreightField'} + apCode: {$ref: '#/components/schemas/FreightField'} + apName: {$ref: '#/components/schemas/FreightField'} + cargoQuantity: {$ref: '#/components/schemas/FreightField'} + transshipment: {$ref: '#/components/schemas/FreightField'} + partialShipment: {$ref: '#/components/schemas/FreightField'} + oceanVesselVoyage: {$ref: '#/components/schemas/FreightField'} + portOfLoading: {$ref: '#/components/schemas/FreightField'} + portOfDischarge: {$ref: '#/components/schemas/FreightField'} + placeOfDelivery: {$ref: '#/components/schemas/FreightField'} + totalPackagesInWords: {$ref: '#/components/schemas/FreightField'} + remark: {$ref: '#/components/schemas/FreightField'} + carrier: {$ref: '#/components/schemas/FreightField'} + bookingAgent: {$ref: '#/components/schemas/FreightField'} + printDate: {$ref: '#/components/schemas/FreightField'} + bookingNumber: {$ref: '#/components/schemas/FreightField'} + ensAmsCutoffDate: {$ref: '#/components/schemas/FreightField'} + customsCutoffDate: {$ref: '#/components/schemas/FreightField'} + expectedDepartureDate: {$ref: '#/components/schemas/FreightField'} + finalDestination: {$ref: '#/components/schemas/FreightField'} + shipperLoadCountSeal: {$ref: '#/components/schemas/FreightField'} + containerSpec: {$ref: '#/components/schemas/FreightField'} + shippedOnBoard: {$ref: '#/components/schemas/FreightField'} + draftText: {$ref: '#/components/schemas/FreightField'} + containerSummary: {$ref: '#/components/schemas/FreightField'} + notes: {$ref: '#/components/schemas/FreightField'} + issuePlace: {$ref: '#/components/schemas/FreightField'} + issueDate: {$ref: '#/components/schemas/FreightField'} cargoRows: type: array + minItems: 1 maxItems: 1 - description: V1 templates render at most one cargo row. Callers must not send additional rows. + description: V1模板固定恰好一行货物,六个字段均必填且非空。 items: $ref: '#/components/schemas/FreightA4CargoRow' FreightA4CargoRow: type: object + required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement] properties: marksAndNos: type: string @@ -220,6 +277,9 @@ components: type: string measurement: type: string + FreightField: + type: string + maxLength: 4000 ApiError: type: object required: [code, message] diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java index cd8ea1512..4a5cd5f93 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -172,6 +172,11 @@ class InternalPdfControllerTest { .contains("application/pdf:") .contains("application/json:") .contains("name: X-Internal-Service-Token") + .contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:") + .contains("BILL_OF_LADING_CONFIRMATION_A4_V1:") + .contains("additionalProperties: false") + .contains("minItems: 1") + .contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]") .contains("maximum: 2000"); } diff --git a/print/src/test/java/com/baiyue/print/renderer/FreightA4ChineseLayoutContractTest.java b/print/src/test/java/com/baiyue/print/renderer/FreightA4ChineseLayoutContractTest.java new file mode 100644 index 000000000..16b5d08b2 --- /dev/null +++ b/print/src/test/java/com/baiyue/print/renderer/FreightA4ChineseLayoutContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.print.renderer; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class FreightA4ChineseLayoutContractTest { + @Test + void freezesChineseTitlesAndBilingualLayoutLabels() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/print/renderer/FreightA4Renderer.java")); + + assertThat(source).contains( + "出口货物托运单", "提单确认件", + "Shipper(Full Name & Address)(托运人)", "Consignee(收货人)", "Notify Party(通知人)", + "Ocean Vessel/Voy(船名航次)", "Port of Loading(起运港)", + "Part of Discharge(卸货港)", "Place of Delivery(交货地)", + "Marks & Nos\\n(标志和号码)", "Description of Goods(品名)", + "Gross Weight(Kilos)\\n毛重(公斤)", "Measurement(CBM)\\n体积(立方米)", + "截关日期", "预计开航日期", "SHIPPED ON BOARD"); + } +} diff --git a/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java index 8fa2c3648..2bc2516d0 100644 --- a/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java @@ -37,7 +37,8 @@ class FreightA4PdfServiceGenerationTest { assertA4(document); String text = new PDFTextStripper().getText(document); assertThat(text).contains("Sea Freight Shipping Instructions", "Job Nbr", - "BYSE2607044", "CAR ACCESSORIES", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."); + "BYSE2607044", "CAR ACCESSORIES", + "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."); } } diff --git a/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java index 74cb9b1ca..ce776c283 100644 --- a/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java +++ b/print/src/test/java/com/baiyue/print/service/FreightA4PdfServiceValidationTest.java @@ -25,7 +25,7 @@ class FreightA4PdfServiceValidationTest { @Test void rejectsUnsupportedTemplateCode() { assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN", - pageSpec(), "a4.pdf", List.of(document())), "templateCode"); + pageSpec(), "a4.pdf", List.of(shippingDocument())), "templateCode"); } @Test @@ -33,18 +33,59 @@ class FreightA4PdfServiceValidationTest { assertValidationFailure(new FreightA4PdfDtos.Request( FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")), - "a4.pdf", List.of(document())), "pageSpec.widthMm"); + "a4.pdf", List.of(shippingDocument())), "pageSpec.widthMm"); } @Test void allowsMultilineDocumentFields() { FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request( FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, - pageSpec(), "a4.pdf", List.of(document())); + pageSpec(), "a4.pdf", List.of(shippingDocument())); assertThatCode(() -> service.generate(request)).doesNotThrowAnyException(); } + @Test + void rejectsMissingRequiredFieldForSelectedTemplate() { + var fields = new java.util.HashMap<>(shippingDocument().fields()); + fields.remove("jobNumber"); + assertValidationFailure(request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + new FreightA4PdfDtos.Document(fields, cargoRows())), "fields.jobNumber is required"); + } + + @Test + void rejectsUnknownFieldInsteadOfSilentlyIgnoringTypo() { + var fields = new java.util.HashMap<>(shippingDocument().fields()); + fields.put("jobNubmer", "OB-260831-0001"); + assertValidationFailure(request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + new FreightA4PdfDtos.Document(fields, cargoRows())), "fields.jobNubmer is not supported"); + } + + @Test + void requiresExactlyOneCompleteCargoRow() { + assertValidationFailure(request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + new FreightA4PdfDtos.Document(shippingDocument().fields(), List.of())), + "cargoRows must contain exactly one item"); + assertValidationFailure(request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + new FreightA4PdfDtos.Document(shippingDocument().fields(), List.of(cargo(), cargo()))), + "cargoRows must contain exactly one item"); + assertValidationFailure(request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + new FreightA4PdfDtos.Document(shippingDocument().fields(), List.of( + new FreightA4PdfDtos.CargoRow("", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM")))), + "marksAndNos must not be blank"); + } + + @Test + void derivesStableChineseFileNamesFromBusinessNumbers() { + var shipping = service.generate(request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + shippingDocument())); + var bill = service.generate(request(FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE, + billDocument())); + + assertThat(shipping.fileName()).isEqualTo("OB-260831-0001-出口货物托运单.pdf"); + assertThat(bill.fileName()).isEqualTo("BK-20260831-提单确认件.pdf"); + } + private void assertValidationFailure(FreightA4PdfDtos.Request request, String messagePart) { assertThatThrownBy(() -> service.generate(request)) .isInstanceOfSatisfying(BizException.class, exception -> { @@ -53,12 +94,36 @@ class FreightA4PdfServiceValidationTest { }); } + private FreightA4PdfDtos.Request request(String template, FreightA4PdfDtos.Document document) { + return new FreightA4PdfDtos.Request(template, pageSpec(), null, List.of(document)); + } + private FreightA4PdfDtos.PageSpec pageSpec() { return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")); } - private FreightA4PdfDtos.Document document() { - return new FreightA4PdfDtos.Document(Map.of("shipper", "A\nB"), - List.of(new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM"))); + private FreightA4PdfDtos.Document shippingDocument() { + return new FreightA4PdfDtos.Document(Map.of( + "toCompany", "订舱代理", "shipper", "A\nB", "consignee", "C", "notifyParty", "N", + "jobNumber", "OB-260831-0001", "cargoQuantity", "1*40HQ", "portOfLoading", "NINGBO", + "portOfDischarge", "LOS ANGELES", "placeOfDelivery", "LOS ANGELES", + "totalPackagesInWords", "SAY ONE PACKAGE ONLY."), cargoRows()); + } + + private FreightA4PdfDtos.Document billDocument() { + return new FreightA4PdfDtos.Document(Map.of( + "shipper", "A", "consignee", "C", "notifyParty", "N", "bookingNumber", "BK-20260831", + "portOfLoading", "NINGBO", "portOfDischarge", "LOS ANGELES", + "placeOfDelivery", "LOS ANGELES", "serviceTerm", "CY-CY", + "freightTerm", "FREIGHT PREPAID", "totalPackagesInWords", "SAY ONE PACKAGE ONLY."), + cargoRows()); + } + + private List cargoRows() { + return List.of(cargo()); + } + + private FreightA4PdfDtos.CargoRow cargo() { + return new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM"); } }