feat(file-print): support ocean booking documents
This commit is contained in:
@@ -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` |
|
| 装柜与业务文件证据 | `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 精确校验 |
|
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `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/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` |
|
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
||||||
| 下游 OCR | `client/WeightOcrClient.java` |
|
| 下游 OCR | `client/WeightOcrClient.java` |
|
||||||
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.util.Arrays;
|
|||||||
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||||
enum BusinessFileCategory {
|
enum BusinessFileCategory {
|
||||||
TRUCK_APPOINTMENT_ATTACHMENT(false, false),
|
TRUCK_APPOINTMENT_ATTACHMENT(false, false),
|
||||||
|
OCEAN_BOOKING_DOCUMENT(true, false),
|
||||||
CUSTOMS_DOCUMENT(false, false),
|
CUSTOMS_DOCUMENT(false, false),
|
||||||
CUSTOMS_RECEIPT(false, false),
|
CUSTOMS_RECEIPT(false, false),
|
||||||
FINANCE_VOUCHER(false, false),
|
FINANCE_VOUCHER(false, false),
|
||||||
|
|||||||
@@ -93,11 +93,12 @@ public class InternalBusinessFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static Metadata metadata(FileObjectEntity value) {
|
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());
|
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) {}
|
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||||
public record Download(String fileName, String contentType, byte[] content) {}
|
public record Download(String fileName, String contentType, byte[] content) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,9 +107,10 @@ components:
|
|||||||
data: {$ref: '#/components/schemas/BusinessFileMetadata'}
|
data: {$ref: '#/components/schemas/BusinessFileMetadata'}
|
||||||
BusinessFileMetadata:
|
BusinessFileMetadata:
|
||||||
type: object
|
type: object
|
||||||
required: [fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes]
|
||||||
properties:
|
properties:
|
||||||
fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输}
|
fileId: {type: string, description: File不可变对象ID,跨前端按字符串传输}
|
||||||
|
ownerScope: {type: string, enum: [CUSTOMER]}
|
||||||
tenantId: {type: string, description: CUSTOMER owner scope客户ID}
|
tenantId: {type: string, description: CUSTOMER owner scope客户ID}
|
||||||
businessType: {type: string, description: fileCategory}
|
businessType: {type: string, description: fileCategory}
|
||||||
businessId: {type: string, description: loadingPlanId}
|
businessId: {type: string, description: loadingPlanId}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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/");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -23,7 +23,7 @@ class InternalBusinessFileControllerTest {
|
|||||||
|
|
||||||
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
|
@Test void zeroTenantUploadAndMetadataRemainBehindInternalToken() {
|
||||||
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
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());
|
"41", "clearance.pdf", "hash", "application/pdf", file.getSize());
|
||||||
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
|
when(service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file)).thenReturn(metadata);
|
||||||
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
|
when(service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)).thenReturn(metadata);
|
||||||
|
|||||||
@@ -69,6 +69,44 @@ class InternalBusinessFileServiceTest {
|
|||||||
verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
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() {
|
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes());
|
MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes());
|
||||||
assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file))
|
assertThatThrownBy(() -> service.upload(-1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file))
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
- 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。
|
- 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。
|
||||||
- 其他模块接入时先读 `README.md` 和 OpenAPI,在调用模块内声明 DTO 并从既有业务数据自动映射;不得依赖 Print 的 Java DTO,不得新增要求用户重复填写打印字段的表单。
|
- 其他模块接入时先读 `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 为准。
|
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
|
||||||
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
|
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
|
||||||
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
|
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
|
||||||
|
|||||||
+8
-4
@@ -112,16 +112,20 @@ X-Request-Id: <optional-correlation-id>
|
|||||||
| `pageSpec.heightMm` | 是 | 固定数值 `297` |
|
| `pageSpec.heightMm` | 是 | 固定数值 `297` |
|
||||||
| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 |
|
| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 |
|
||||||
| `documents` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 |
|
| `documents` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 |
|
||||||
| `documents[].fields` | 否 | 字符串字典,字段值允许换行,单值最多 4000 字符 |
|
| `documents[].fields` | 是 | 只能使用所选模板登记的键;未知键按 `A0422` 拒绝,字段值允许换行,单值最多 4000 字符 |
|
||||||
| `documents[].cargoRows` | 否 | 0-1 项;当前 V1 模板只渲染一行货物,调用方不得发送第二行 |
|
| `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`。
|
托运书模板支持的完整 `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`。
|
提单确认件模板支持的完整 `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. 响应处理
|
## 3. 响应处理
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,7 @@ public class InternalPdfController {
|
|||||||
@Valid @RequestBody FreightA4PdfDtos.Request request,
|
@Valid @RequestBody FreightA4PdfDtos.Request request,
|
||||||
HttpServletRequest servletRequest) {
|
HttpServletRequest servletRequest) {
|
||||||
tokenGuard.verify(internalToken);
|
tokenGuard.verify(internalToken);
|
||||||
String requestId = CorrelationContext.requestId(servletRequest);
|
FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.generate(request);
|
||||||
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);
|
|
||||||
byte[] content = pdf.content();
|
byte[] content = pdf.content();
|
||||||
|
|
||||||
ContentDisposition disposition = ContentDisposition.attachment()
|
ContentDisposition disposition = ContentDisposition.attachment()
|
||||||
|
|||||||
@@ -27,7 +27,25 @@ public class FreightA4PdfService {
|
|||||||
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
|
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_KEY_CODE_POINTS = 80;
|
||||||
private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000;
|
private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000;
|
||||||
private static final int MAX_CARGO_ROWS = 50;
|
private static final Set<String> 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<String> SHIPPING_REQUIRED_FIELDS = Set.of(
|
||||||
|
"toCompany", "shipper", "consignee", "notifyParty", "jobNumber", "cargoQuantity",
|
||||||
|
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
|
||||||
|
private static final Set<String> 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<String> 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_WIDTH_MM = new BigDecimal("210");
|
||||||
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
|
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
|
||||||
private static final Set<String> SUPPORTED_TEMPLATES = Set.of(
|
private static final Set<String> SUPPORTED_TEMPLATES = Set.of(
|
||||||
@@ -99,35 +117,45 @@ public class FreightA4PdfService {
|
|||||||
|
|
||||||
List<FreightA4PdfDtos.Document> documents = new ArrayList<>(request.documents().size());
|
List<FreightA4PdfDtos.Document> documents = new ArrayList<>(request.documents().size());
|
||||||
for (int index = 0; index < request.documents().size(); index++) {
|
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));
|
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) {
|
if (document == null) {
|
||||||
invalid("documents[" + index + "] is required");
|
invalid("documents[" + index + "] is required");
|
||||||
}
|
}
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
if (document.fields() != null) {
|
if (document.fields() != null) {
|
||||||
|
Set<String> allowed = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)
|
||||||
|
? SHIPPING_FIELDS : BILL_FIELDS;
|
||||||
for (Map.Entry<String, String> entry : document.fields().entrySet()) {
|
for (Map.Entry<String, String> entry : document.fields().entrySet()) {
|
||||||
String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key"));
|
String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key"));
|
||||||
if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) {
|
if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) {
|
||||||
invalid(field(index, "fields." + key) + " key is too long");
|
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)));
|
fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List<FreightA4PdfDtos.CargoRow> rows = new ArrayList<>();
|
Set<String> required = FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)
|
||||||
if (document.cargoRows() != null) {
|
? SHIPPING_REQUIRED_FIELDS : BILL_REQUIRED_FIELDS;
|
||||||
if (document.cargoRows().size() > MAX_CARGO_ROWS) {
|
for (String key : required) {
|
||||||
invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items");
|
if (!fields.containsKey(key) || fields.get(key).isBlank()) {
|
||||||
}
|
invalid(field(index, "fields." + key) + " is required by " + templateCode);
|
||||||
for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) {
|
|
||||||
rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
List<FreightA4PdfDtos.CargoRow> 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));
|
return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,19 +166,22 @@ public class FreightA4PdfService {
|
|||||||
}
|
}
|
||||||
String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "].";
|
String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "].";
|
||||||
return new FreightA4PdfDtos.CargoRow(
|
return new FreightA4PdfDtos.CargoRow(
|
||||||
normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"),
|
normalizeRequiredBlock(row.marksAndNos(), prefix + "marksAndNos"),
|
||||||
normalizeOptionalBlock(row.quantity(), prefix + "quantity"),
|
normalizeRequiredBlock(row.quantity(), prefix + "quantity"),
|
||||||
normalizeOptionalBlock(row.packaging(), prefix + "packaging"),
|
normalizeRequiredBlock(row.packaging(), prefix + "packaging"),
|
||||||
normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
|
normalizeRequiredBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
|
||||||
normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"),
|
normalizeRequiredBlock(row.grossWeight(), prefix + "grossWeight"),
|
||||||
normalizeOptionalBlock(row.measurement(), prefix + "measurement"));
|
normalizeRequiredBlock(row.measurement(), prefix + "measurement"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeFileName(String value) {
|
private String normalizeFileName(String value, String templateCode, FreightA4PdfDtos.Document firstDocument) {
|
||||||
if (value == null) {
|
String candidate = value;
|
||||||
return "freight-a4-documents.pdf";
|
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) {
|
if (normalized.codePointCount(0, normalized.length()) > MAX_FILE_NAME_CODE_POINTS) {
|
||||||
invalid("downloadFileName must not exceed 120 characters");
|
invalid("downloadFileName must not exceed 120 characters");
|
||||||
}
|
}
|
||||||
@@ -165,6 +196,12 @@ public class FreightA4PdfService {
|
|||||||
return normalized;
|
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) {
|
private String normalizeRequiredLine(String value, String field) {
|
||||||
String normalized = normalizeOptionalBlock(value, field);
|
String normalized = normalizeOptionalBlock(value, field);
|
||||||
if (normalized.isBlank()) {
|
if (normalized.isBlank()) {
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
maxLength: 120
|
maxLength: 120
|
||||||
example: BYSE2607044-托运书.pdf
|
example: BYSE2607044-托运书.pdf
|
||||||
|
description: 未传时托运书为{jobNumber}-出口货物托运单.pdf,提单确认件为{bookingNumber}-提单确认件.pdf
|
||||||
documents:
|
documents:
|
||||||
type: array
|
type: array
|
||||||
minItems: 1
|
minItems: 1
|
||||||
@@ -192,21 +193,77 @@ components:
|
|||||||
enum: [297]
|
enum: [297]
|
||||||
FreightA4Document:
|
FreightA4Document:
|
||||||
type: object
|
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:
|
properties:
|
||||||
fields:
|
fields:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties: false
|
||||||
type: string
|
description: 只能提交所选模板登记的键;未知键和缺少必填键返回A0422。
|
||||||
maxLength: 4000
|
properties:
|
||||||
description: Template field map. Known keys are documented in the print module README.
|
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:
|
cargoRows:
|
||||||
type: array
|
type: array
|
||||||
|
minItems: 1
|
||||||
maxItems: 1
|
maxItems: 1
|
||||||
description: V1 templates render at most one cargo row. Callers must not send additional rows.
|
description: V1模板固定恰好一行货物,六个字段均必填且非空。
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/FreightA4CargoRow'
|
$ref: '#/components/schemas/FreightA4CargoRow'
|
||||||
FreightA4CargoRow:
|
FreightA4CargoRow:
|
||||||
type: object
|
type: object
|
||||||
|
required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]
|
||||||
properties:
|
properties:
|
||||||
marksAndNos:
|
marksAndNos:
|
||||||
type: string
|
type: string
|
||||||
@@ -220,6 +277,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
measurement:
|
measurement:
|
||||||
type: string
|
type: string
|
||||||
|
FreightField:
|
||||||
|
type: string
|
||||||
|
maxLength: 4000
|
||||||
ApiError:
|
ApiError:
|
||||||
type: object
|
type: object
|
||||||
required: [code, message]
|
required: [code, message]
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ class InternalPdfControllerTest {
|
|||||||
.contains("application/pdf:")
|
.contains("application/pdf:")
|
||||||
.contains("application/json:")
|
.contains("application/json:")
|
||||||
.contains("name: X-Internal-Service-Token")
|
.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");
|
.contains("maximum: 2000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,8 @@ class FreightA4PdfServiceGenerationTest {
|
|||||||
assertA4(document);
|
assertA4(document);
|
||||||
String text = new PDFTextStripper().getText(document);
|
String text = new PDFTextStripper().getText(document);
|
||||||
assertThat(text).contains("Sea Freight Shipping Instructions", "Job Nbr",
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-6
@@ -25,7 +25,7 @@ class FreightA4PdfServiceValidationTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsUnsupportedTemplateCode() {
|
void rejectsUnsupportedTemplateCode() {
|
||||||
assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN",
|
assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN",
|
||||||
pageSpec(), "a4.pdf", List.of(document())), "templateCode");
|
pageSpec(), "a4.pdf", List.of(shippingDocument())), "templateCode");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -33,18 +33,59 @@ class FreightA4PdfServiceValidationTest {
|
|||||||
assertValidationFailure(new FreightA4PdfDtos.Request(
|
assertValidationFailure(new FreightA4PdfDtos.Request(
|
||||||
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
||||||
new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")),
|
new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")),
|
||||||
"a4.pdf", List.of(document())), "pageSpec.widthMm");
|
"a4.pdf", List.of(shippingDocument())), "pageSpec.widthMm");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void allowsMultilineDocumentFields() {
|
void allowsMultilineDocumentFields() {
|
||||||
FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request(
|
FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request(
|
||||||
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
||||||
pageSpec(), "a4.pdf", List.of(document()));
|
pageSpec(), "a4.pdf", List.of(shippingDocument()));
|
||||||
|
|
||||||
assertThatCode(() -> service.generate(request)).doesNotThrowAnyException();
|
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) {
|
private void assertValidationFailure(FreightA4PdfDtos.Request request, String messagePart) {
|
||||||
assertThatThrownBy(() -> service.generate(request))
|
assertThatThrownBy(() -> service.generate(request))
|
||||||
.isInstanceOfSatisfying(BizException.class, exception -> {
|
.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() {
|
private FreightA4PdfDtos.PageSpec pageSpec() {
|
||||||
return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
|
return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private FreightA4PdfDtos.Document document() {
|
private FreightA4PdfDtos.Document shippingDocument() {
|
||||||
return new FreightA4PdfDtos.Document(Map.of("shipper", "A\nB"),
|
return new FreightA4PdfDtos.Document(Map.of(
|
||||||
List.of(new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM")));
|
"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<FreightA4PdfDtos.CargoRow> cargoRows() {
|
||||||
|
return List.of(cargo());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FreightA4PdfDtos.CargoRow cargo() {
|
||||||
|
return new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user