From cc7c2f09548baa1caf2f564fe45ddd87670c240f Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 11 Sep 2026 15:38:19 +0800
Subject: [PATCH] feat: support mixed-SKU dropship cartons
---
deploy/sql/头程数据库统一增量-MySQL8.sql | 35 ++++
firstleg/AGENTS.md | 6 +-
.../firstleg/client/OrderCargoClient.java | 6 +-
.../firstleg/client/PrintPdfClient.java | 16 ++
.../dto/DropshipPalletizationDtos.java | 21 ++-
.../mapper/DropshipPalletizationMapper.java | 40 ++++-
.../mapper/InboundCartonLabelSkuMapper.java | 48 +++++
.../service/DropshipPalletizationService.java | 69 +++++---
.../InboundCartonLabelPrintService.java | 29 +--
.../service/InboundCartonLabelService.java | 59 ++++++-
.../db/phase2-inbound-carton-label.sql | 16 ++
.../resources/db/phase2-inbound-schema.sql | 33 ++++
.../openapi/dropship-palletization-v1.yaml | 44 +++--
.../DropshipPalletizationServiceTest.java | 17 +-
.../InboundCartonLabelPrintServiceTest.java | 20 ++-
.../InboundCartonLabelServiceTest.java | 29 +++
.../InternalOrderCargoController.java | 14 +-
.../com/baiyue/order/dto/OrderRequests.java | 23 ++-
.../baiyue/order/entity/OrderCargoEntity.java | 2 +-
.../MerchantInboundOrderListService.java | 8 +-
.../service/OrderApplicationService.java | 99 +++++++++--
.../service/OrderCargoPackageCounts.java | 24 +++
.../service/OrderInboundOutboxPublisher.java | 9 +-
.../PrintableInboundSnapshotService.java | 5 +-
.../service/UnifiedOrderQueryService.java | 12 +-
order/src/main/resources/db/order.sql | 1 +
.../resources/db/phase1-order-extension.sql | 1 +
.../src/main/resources/openapi/order-v1.yaml | 9 +-
...MerchantBatchDraftOpenApiContractTest.java | 2 +
.../OrderApplicationServiceStateTest.java | 79 ++++++++-
.../service/OrderCargoPackageCountsTest.java | 29 +++
print/AGENTS.md | 10 +-
print/README.md | 3 +-
.../controller/InternalPdfController.java | 16 ++
.../print/dto/DropshipPalletA4PdfDtos.java | 23 +++
.../renderer/DropshipPalletA4Renderer.java | 167 ++++++++++++++++++
.../print/renderer/PdfBarcodeRenderer.java | 36 ++++
.../service/DropshipPalletA4PdfService.java | 100 +++++++++++
.../resources/openapi/print-internal-v1.yaml | 63 +++++++
.../controller/InternalPdfControllerTest.java | 53 ++++++
.../renderer/PdfBarcodeRendererTest.java | 22 +++
...pshipPalletA4PdfServiceGenerationTest.java | 93 ++++++++++
42 files changed, 1263 insertions(+), 128 deletions(-)
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundCartonLabelSkuMapper.java
create mode 100644 order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
create mode 100644 order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
create mode 100644 print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java
create mode 100644 print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java
create mode 100644 print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java
create mode 100644 print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java
create mode 100644 print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java
create mode 100644 print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java
diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql
index 459e78656..bb8add3c1 100644
--- a/deploy/sql/头程数据库统一增量-MySQL8.sql
+++ b/deploy/sql/头程数据库统一增量-MySQL8.sql
@@ -1793,6 +1793,7 @@ CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NU
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
+CALL phase1_add_column('ord_order_cargo', 'packing_group_no', "VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱'");
CALL phase1_add_column('ord_order_cargo', 'wooden_product', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检'");
UPDATE ord_order_cargo cargo
@@ -2906,6 +2907,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
+CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku (
+ id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
+ inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
+ carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID',
+ inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
+ sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
+ cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
+ expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量',
+ line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序',
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id),
+ KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code),
+ CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
+) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照';
+
-- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
@@ -18242,6 +18260,23 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细';
+CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku (
+ id BIGINT UNSIGNED NOT NULL COMMENT '清点箱SKU明细ID',
+ carton_id BIGINT UNSIGNED NOT NULL COMMENT '清点物理箱ID',
+ inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
+ sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
+ cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
+ expected_quantity INT UNSIGNED NOT NULL COMMENT '应收数量',
+ actual_quantity INT UNSIGNED NOT NULL COMMENT '实收数量',
+ discrepancy_status VARCHAR(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE',
+ discrepancy_remark VARCHAR(500) NULL COMMENT '差异处理说明',
+ line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU顺序',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_dropship_carton_sku_detail (carton_id,inbound_detail_id),
+ KEY idx_dropship_carton_sku_code (sku_code,carton_id),
+ CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id)
+) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实';
+
-- 已存在开发库直接收敛到逐箱清点与整托分柜结构;新建库由上面的最新定义一次完成。
DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema;
diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md
index d40a3a231..32956daa5 100644
--- a/firstleg/AGENTS.md
+++ b/firstleg/AGENTS.md
@@ -19,8 +19,8 @@
| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 |
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
-| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
-| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成可打印 `DP` 托盘码并下发独立任务,不创建国内库存;PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
+| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成,打印面显示箱内 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
+| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
@@ -65,7 +65,7 @@
- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
-- 一件代发以 `fl_inbound_carton_label.sku_code` 作为不可变 SKU 快照;PDA 仍只扫描稳定 `box_code`,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;历史箱唛仅能在同客户、同入库单范围内按 `inbound_detail_id` 回填,禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
+- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
index ee11c2d60..45387e73e 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java
@@ -65,18 +65,18 @@ public interface OrderCargoClient {
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, Integer unitsPerCarton,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
- String shipmentId) {
+ String shipmentId, String packingGroupNo) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null,
- packageCount, null, lengthCm, widthCm, heightCm, null);
+ packageCount, null, lengthCm, widthCm, heightCm, null, null);
}
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, String shipmentId) {
this(cargoId, orderId, itemNo, cargoName, skuNo,
- packageCount, null, lengthCm, widthCm, heightCm, shipmentId);
+ packageCount, null, lengthCm, widthCm, heightCm, shipmentId, null);
}
}
}
diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java
index 923ba2e5c..1275279de 100644
--- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java
+++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java
@@ -26,6 +26,12 @@ public interface PrintPdfClient {
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody FreightA4PdfRequest request);
+ @PostMapping(value = "/dropship-pallet-a4-documents", consumes = "application/json")
+ ResponseEntity generateDropshipPalletA4Documents(
+ @RequestHeader("X-Internal-Service-Token") String token,
+ @RequestHeader(value = "X-Request-Id", required = false) String requestId,
+ @RequestBody DropshipPalletA4PdfRequest request);
+
record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
- @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct;
+ @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct; private String packingGroupNo;
}
diff --git a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
index 6772a5ca1..94ed30daf 100644
--- a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
+++ b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java
@@ -84,9 +84,8 @@ public class MerchantInboundOrderListService {
List missing, Map> cargoes) {
try {
List commands = missing.stream().map(order -> {
- int packages = cargoes.getOrDefault(order.getId(), List.of()).stream()
- .map(OrderCargoEntity::getPackageCount).filter(Objects::nonNull)
- .mapToInt(Integer::intValue).sum();
+ int packages = OrderCargoPackageCounts.physical(
+ cargoes.getOrDefault(order.getId(), List.of()), order.getFulfillmentType());
return OrderApplicationService.inboundCommand(order, packages);
}).toList();
var response = firstleg.prepareBatch(internalToken, commands);
@@ -105,8 +104,7 @@ public class MerchantInboundOrderListService {
OrderPartyEntity consignee,
OrderStatusHistoryEntity latestHistory,
FirstlegInboundClient.InboundReference inbound) {
- int packageCount = cargoes.stream().map(OrderCargoEntity::getPackageCount)
- .filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
+ int packageCount = OrderCargoPackageCounts.physical(cargoes, order.getFulfillmentType());
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
BigDecimal grossWeight = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
index de11b6b0b..d9c3d0825 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
@@ -31,9 +31,13 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.time.*;
import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
import java.util.stream.Collectors;
import java.time.format.DateTimeFormatter;
@@ -81,6 +85,7 @@ public class OrderApplicationService {
OrderRequests.SaveRequest request) {
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
validateWoodInspection(request);
+ validateRequestCargoQuantities(request.cargoes(), false);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
@@ -122,6 +127,7 @@ public class OrderApplicationService {
validateWoodInspection(request);
OrderEntity order = require(tenantId, id);
if (allowSubmitted) validateCompleteRequest(request);
+ else validateRequestCargoQuantities(request.cargoes(), false);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
OrderStatus before = order.getStatus();
@@ -296,7 +302,8 @@ public class OrderApplicationService {
}
var cargoes = repository.findCargo(tenantId, order.getId());
if (cargoes == null || cargoes.isEmpty()) throw new BizException("At least one cargo line is required");
- for (var cargo : cargoes) {
+ for (int cargoIndex = 0; cargoIndex < cargoes.size(); cargoIndex++) {
+ var cargo = cargoes.get(cargoIndex);
if (!StringUtils.hasText(cargo.getCargoName()) || !StringUtils.hasText(cargo.getPackageType())
|| cargo.getPackageCount() == null || cargo.getPackageCount() < 1
|| cargo.getGrossWeightKg() == null || cargo.getGrossWeightKg().signum() <= 0
@@ -304,6 +311,7 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED,
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
}
+ validatePersistedCargoQuantity(cargo, cargoIndex, true);
if (Boolean.TRUE.equals(cargo.getWoodenProduct()) && !Boolean.TRUE.equals(order.getCustomsInspection())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
}
@@ -343,6 +351,7 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED, "私人目的仓必须选择客户地址");
}
validateWoodInspection(request);
+ validateRequestCargoQuantities(request.cargoes(), true);
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.cargoName()) || !StringUtils.hasText(cargo.packageType())
|| cargo.packageCount() < 1 || cargo.grossWeightKg() == null
@@ -352,6 +361,7 @@ public class OrderApplicationService {
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
}
}
+ validateMixedCartons(request);
if ("FBA".equals(request.destinationType())) {
if (!StringUtils.hasText(request.amazonWarehouseCode()) || !StringUtils.hasText(request.shipmentId())
|| !StringUtils.hasText(request.referenceId())) {
@@ -368,6 +378,36 @@ public class OrderApplicationService {
}
}
+ private void validateRequestCargoQuantities(List cargoes, boolean required) {
+ for (int index = 0; index < cargoes.size(); index++) {
+ OrderRequests.Cargo cargo = cargoes.get(index);
+ Integer unitsPerPackage = cargo.unitsPerPackage();
+ Integer totalQuantity = cargo.totalQuantity();
+ if (unitsPerPackage == null && totalQuantity == null && !required) continue;
+ validateCargoQuantity(unitsPerPackage, cargo.packageCount(), totalQuantity, index, required);
+ }
+ }
+
+ private void validatePersistedCargoQuantity(OrderCargoEntity cargo, int index, boolean required) {
+ validateCargoQuantity(cargo.getUnitsPerPackage(), cargo.getPackageCount(), cargo.getTotalQuantity(), index, required);
+ }
+
+ private void validateCargoQuantity(Integer unitsPerPackage, Integer packageCount, Integer totalQuantity,
+ int index, boolean required) {
+ if (unitsPerPackage == null || packageCount == null || totalQuantity == null) {
+ if (required || unitsPerPackage != null || totalQuantity != null) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "第 " + (index + 1) + " 条货物必须填写每箱数量、箱数和总数量");
+ }
+ return;
+ }
+ long expected = (long) unitsPerPackage * packageCount;
+ if (totalQuantity.longValue() != expected) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "第 " + (index + 1) + " 条货物总数量应等于每箱数量 × 箱数(" + expected + ")");
+ }
+ }
+
private boolean hasParty(List parties, String partyType) {
return parties != null && parties.stream()
.anyMatch(party -> party != null && partyType.equals(party.getPartyType()));
@@ -601,6 +641,7 @@ public class OrderApplicationService {
item.setReferenceId(cargo.referenceId());
item.setProductImageFileId(cargo.productImageFileId());
item.setWoodenProduct(cargo.woodenProduct());
+ item.setPackingGroupNo(normalize(cargo.packingGroupNo()));
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
if (addresses.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", addresses.sender());
@@ -706,8 +747,7 @@ public class OrderApplicationService {
order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType(), order.getLxCode());
}
private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) {
- int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount)
- .filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum();
+ int packageCount = physicalPackageCountFromRequests(request.cargoes(), request.fulfillmentType());
prepareSubmittedInbound(order, packageCount);
}
@@ -716,11 +756,7 @@ public class OrderApplicationService {
* 订单审核仍由 Order 独立控制,Firstleg 不得据此推进测量审核或后续履约。
*/
private void prepareSubmittedInbound(OrderEntity order) {
- long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
- .map(OrderCargoEntity::getPackageCount)
- .filter(java.util.Objects::nonNull)
- .mapToLong(Integer::longValue)
- .sum();
+ long total = physicalPackageCountFromEntities(repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
prepareSubmittedInbound(order, total > 0 && total <= 1_000_000 ? (int) total : null);
}
@@ -745,16 +781,53 @@ public class OrderApplicationService {
}
}
private int totalPackageCount(OrderEntity order) {
- long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
- .map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount)
- .filter(java.util.Objects::nonNull)
- .mapToLong(Integer::longValue)
- .sum();
+ long total = physicalPackageCountFromEntities(repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
if (total < 1 || total > 1_000_000) {
throw new BizException("订单货物总箱数必须为1至1000000箱");
}
return (int) total;
}
+ private void validateMixedCartons(OrderRequests.SaveRequest request) {
+ if (!"ONE_CLICK_DROPSHIP".equals(request.fulfillmentType())) return;
+ Map firstByGroup = new LinkedHashMap<>();
+ for (OrderRequests.Cargo cargo : request.cargoes()) {
+ String group = normalize(cargo.packingGroupNo());
+ if (group == null) continue;
+ OrderRequests.Cargo first = firstByGroup.putIfAbsent(group, cargo);
+ if (first != null && (first.packageCount() != cargo.packageCount()
+ || !Objects.equals(first.packageType(), cargo.packageType())
+ || !sameDecimal(first.lengthCm(), cargo.lengthCm())
+ || !sameDecimal(first.widthCm(), cargo.widthCm())
+ || !sameDecimal(first.heightCm(), cargo.heightCm()))) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "同一混箱组合的箱数、包装类型和箱规必须一致:" + group);
+ }
+ }
+ }
+ private static int physicalPackageCountFromRequests(List cargoes, String fulfillmentType) {
+ Set countedGroups = new HashSet<>();
+ return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
+ String group = normalize(cargo.packingGroupNo());
+ if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) return cargo.packageCount();
+ return countedGroups.add(group) ? cargo.packageCount() : 0;
+ }).sum();
+ }
+ private static int physicalPackageCountFromEntities(List cargoes, String fulfillmentType) {
+ Set countedGroups = new HashSet<>();
+ return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
+ String group = normalize(cargo.getPackingGroupNo());
+ if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) {
+ return cargo.getPackageCount() == null ? 0 : cargo.getPackageCount();
+ }
+ return countedGroups.add(group) && cargo.getPackageCount() != null ? cargo.getPackageCount() : 0;
+ }).sum();
+ }
+ private static boolean sameDecimal(java.math.BigDecimal left, java.math.BigDecimal right) {
+ return left == null ? right == null : right != null && left.compareTo(right) == 0;
+ }
+ private static String normalize(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
private String nextNo(long orderId) {
return "FL" + orderId;
}
diff --git a/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java b/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
new file mode 100644
index 000000000..555357944
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/service/OrderCargoPackageCounts.java
@@ -0,0 +1,24 @@
+package com.baiyue.order.service;
+
+import com.baiyue.order.entity.OrderCargoEntity;
+import org.springframework.util.StringUtils;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/** Calculates physical cartons without double-counting SKU lines in a mixed-carton group. */
+public final class OrderCargoPackageCounts {
+ private OrderCargoPackageCounts() {}
+
+ public static int physical(List cargoes, String fulfillmentType) {
+ Set countedGroups = new HashSet<>();
+ return cargoes.stream().filter(Objects::nonNull).mapToInt(cargo -> {
+ int count = cargo.getPackageCount() == null ? 0 : cargo.getPackageCount();
+ String group = StringUtils.hasText(cargo.getPackingGroupNo()) ? cargo.getPackingGroupNo().trim() : null;
+ if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType) || group == null) return count;
+ return countedGroups.add(group) ? count : 0;
+ }).sum();
+ }
+}
diff --git a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java
index 67caa0a69..37cd57cbf 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderInboundOutboxPublisher.java
@@ -141,12 +141,9 @@ public class OrderInboundOutboxPublisher {
}
private int totalPackageCount(com.baiyue.order.entity.OrderEntity order) {
- long total = repository.findCargo(order.getTenantId(), order.getId()).stream()
- .map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount)
- .filter(java.util.Objects::nonNull)
- .mapToLong(Integer::longValue)
- .sum();
- return total > 0 && total <= 1_000_000 ? (int) total : 0;
+ int total = OrderCargoPackageCounts.physical(
+ repository.findCargo(order.getTenantId(), order.getId()), order.getFulfillmentType());
+ return total > 0 && total <= 1_000_000 ? total : 0;
}
private void failPermanently(OutboxEventEntity event, String reason) {
diff --git a/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java
index a789cf392..aedad4660 100644
--- a/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java
+++ b/order/src/main/java/com/baiyue/order/service/PrintableInboundSnapshotService.java
@@ -48,9 +48,8 @@ public class PrintableInboundSnapshotService {
}
}
}
- int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream()
- .map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull)
- .mapToInt(Integer::intValue).sum();
+ int packageCount = OrderCargoPackageCounts.physical(
+ orders.findCargoInheritedByOrder(tenantId, orderId), order.getFulfillmentType());
return OrderApplicationService.inboundCommand(order, packageCount);
}
diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
index 599cdb311..779068add 100644
--- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
+++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java
@@ -130,7 +130,7 @@ public class UnifiedOrderQueryService {
.findFirst()
.orElse(null));
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote,
- childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()), creatorName,
+ childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes(), order.getFulfillmentType()), creatorName,
coLoadGroups(List.of(orderId), includeCoLoadGroups).get(orderId));
List timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
@@ -492,7 +492,7 @@ public class UnifiedOrderQueryService {
private record CargoSummary(List skuNos, List shipmentIds, List referenceIds,
Integer totalQuantity, Integer totalPackageCount, BigDecimal grossWeightKg,
BigDecimal netWeightKg, BigDecimal volumeCbm) {
- private static CargoSummary from(List cargoes) {
+ private static CargoSummary from(List cargoes, String fulfillmentType) {
List skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
List shipmentIds = cargoes.stream().map(OrderCargoEntity::getShipmentId)
@@ -501,10 +501,10 @@ public class UnifiedOrderQueryService {
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
int totalQuantity = cargoes.stream().map(OrderCargoEntity::getTotalQuantity)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
- List packageCounts = cargoes.stream().map(OrderCargoEntity::getPackageCount)
- .filter(Objects::nonNull).toList();
- Integer totalPackageCount = packageCounts.isEmpty() ? null
- : Math.toIntExact(packageCounts.stream().mapToLong(Integer::longValue).sum());
+ boolean hasPackageCount = cargoes.stream().anyMatch(cargo -> cargo.getPackageCount() != null);
+ Integer totalPackageCount = hasPackageCount
+ ? OrderCargoPackageCounts.physical(cargoes, fulfillmentType)
+ : null;
BigDecimal grossWeightKg = sum(cargoes, OrderCargoEntity::getGrossWeightKg);
BigDecimal netWeightKg = sum(cargoes, OrderCargoEntity::getNetWeightKg);
BigDecimal volumeCbm = sum(cargoes, OrderCargoEntity::getVolumeCbm);
diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql
index d4574aaeb..7c3d24e80 100644
--- a/order/src/main/resources/db/order.sql
+++ b/order/src/main/resources/db/order.sql
@@ -304,6 +304,7 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检',
+ packing_group_no VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
diff --git a/order/src/main/resources/db/phase1-order-extension.sql b/order/src/main/resources/db/phase1-order-extension.sql
index 00dd9e56b..d6957530c 100644
--- a/order/src/main/resources/db/phase1-order-extension.sql
+++ b/order/src/main/resources/db/phase1-order-extension.sql
@@ -60,6 +60,7 @@ CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NU
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'File服务中的产品图片逻辑文件ID'");
+CALL phase1_add_column('ord_order_cargo', 'packing_group_no', "VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱'");
-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。
UPDATE ord_order_cargo cargo
diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml
index 787828a1c..1096cd56b 100644
--- a/order/src/main/resources/openapi/order-v1.yaml
+++ b/order/src/main/resources/openapi/order-v1.yaml
@@ -477,6 +477,7 @@ paths:
/api/merchant/v1/orders/first-leg/{id}/submit:
post:
summary: 提交订单
+ description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey'
@@ -581,6 +582,7 @@ paths:
/api/platform/v1/orders/first-leg/{id}/submit:
post:
summary: 平台代客提交指定订单
+ description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId'
@@ -1258,11 +1260,12 @@ components:
lengthCm: {type: number, minimum: 0, description: 箱规长(厘米)}
widthCm: {type: number, minimum: 0, description: 箱规宽(厘米)}
heightCm: {type: number, minimum: 0, description: 箱规高(厘米)}
- unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量}
+ unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量;提交时必填}
packageCount: {type: integer, minimum: 1, description: 箱数}
+ packingGroupNo: {type: string, maxLength: 32, description: 一件代发混箱组合号;相同组合号的货物行共享同一批物理箱,组内箱数、包装类型和箱规必须一致}
grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重}
volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积}
- totalQuantity: {type: integer, minimum: 1, description: 总数量}
+ totalQuantity: {type: integer, minimum: 1, description: 总数量;提交时必填且必须等于每箱数量 × 箱数}
netWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总净重}
unitDeclaredValue: {type: number, minimum: 0, description: 清关申报单价(USD)}
declaredValue: {type: number, minimum: 0, description: 清关申报金额(USD)}
@@ -1282,7 +1285,7 @@ components:
woodenProduct: {type: boolean, description: 木制品权威判定快照;为 true 时订单 customsInspection 必须为 true}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
- productImageFileId 必须属于当前登录客户。
+ productImageFileId 必须属于当前登录客户。一件代发中,相同 packingGroupNo 的多条 SKU 行属于同一批物理箱,物理箱数只计算一次。
FirstLegOrderSummary:
type: object
properties:
diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java
index e685ef60d..0b7954d81 100644
--- a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java
+++ b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java
@@ -52,6 +52,8 @@ class MerchantBatchDraftOpenApiContractTest {
assertThat(openapi)
.contains("/api/merchant/v1/orders/first-leg:")
.contains("/api/merchant/v1/orders/first-leg/{id}/submit:")
+ .contains("总数量必须等于每箱数量 × 箱数")
+ .contains("总数量;提交时必填且必须等于每箱数量 × 箱数")
.contains("$ref: '#/components/schemas/FirstLegOrderDraft'");
}
}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
index 98f67b1e7..bf6ea159b 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
@@ -103,6 +103,21 @@ class OrderApplicationServiceStateTest {
&& "自动化联调测试,请勿实际出货".equals(saved.getCargoRemark())));
}
+ @Test
+ void creatingDraftRejectsMismatchedCargoTotalQuantity() {
+ OrderRepository repository = mock(OrderRepository.class);
+ OrderRequests.SaveRequest request = requestWithCargoQuantities(5, 24, 119);
+
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
+ .createDraft(10001L, 2L, request, "mismatched-quantity-on-create"))
+ .isInstanceOf(BizException.class)
+ .hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
+ .extracting(exception -> ((BizException) exception).getResultCode())
+ .isEqualTo(ResultCode.VALIDATION_FAILED);
+
+ verify(repository, never()).insertOrder(any());
+ }
+
@Test
void creatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
@@ -194,6 +209,22 @@ class OrderApplicationServiceStateTest {
ordered.verify(repository).saveOrder(10001L, order);
}
+ @Test
+ void updatingDraftRejectsMismatchedCargoTotalQuantity() {
+ OrderRepository repository = mock(OrderRepository.class);
+ OrderEntity order = persistedOrder(OrderStatus.DRAFT);
+ when(repository.findOwned(10001L, 1L)).thenReturn(order);
+
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
+ .updateDraft(10001L, 2L, 1L, requestWithCargoQuantities(5, 24, 119)))
+ .isInstanceOf(BizException.class)
+ .hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
+ .extracting(exception -> ((BizException) exception).getResultCode())
+ .isEqualTo(ResultCode.VALIDATION_FAILED);
+
+ verify(repository, never()).saveOrder(anyLong(), any());
+ }
+
@Test
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
OrderRepository repository = mock(OrderRepository.class);
@@ -584,6 +615,8 @@ class OrderApplicationServiceStateTest {
cargo.setPackageCount(1);
cargo.setGrossWeightKg(BigDecimal.ONE);
cargo.setVolumeCbm(BigDecimal.ONE);
+ cargo.setUnitsPerPackage(1);
+ cargo.setTotalQuantity(1);
when(repository.findOwned(20002L, 1L)).thenReturn(order);
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
when(repository.findParties(20002L, 1L)).thenReturn(List.of());
@@ -617,6 +650,27 @@ class OrderApplicationServiceStateTest {
verify(repository, never()).saveOrder(anyLong(), any());
}
+ @Test
+ void submittingDraftRejectsPersistedMismatchedCargoTotalQuantity() {
+ OrderRepository repository = mock(OrderRepository.class);
+ OrderEntity order = persistedDraft("SELF_EXPRESS", "OVERSEAS");
+ OrderCargoEntity cargo = completeCargo();
+ cargo.setPackageCount(5);
+ cargo.setUnitsPerPackage(24);
+ cargo.setTotalQuantity(119);
+ when(repository.findOwned(20002L, 1L)).thenReturn(order);
+ when(repository.findCargo(20002L, 1L)).thenReturn(List.of(cargo));
+
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
+ .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null))
+ .isInstanceOf(BizException.class)
+ .hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
+ .extracting(exception -> ((BizException) exception).getResultCode())
+ .isEqualTo(ResultCode.VALIDATION_FAILED);
+
+ verify(repository, never()).saveOrder(anyLong(), any());
+ }
+
@Test
void updatingAnotherCustomersOrderStopsBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);
@@ -672,7 +726,7 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
- null, null, null, null, null, null, null, null, null,
+ null, 1, 1, null, null, null, null, null, null,
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
7L, "客户", "SEA", null, "US", null, "目的仓", null, null,
@@ -699,7 +753,7 @@ class OrderApplicationServiceStateTest {
var cargo = new OrderRequests.Cargo(
"货物", null, "CARTON", 1, BigDecimal.ONE, BigDecimal.ONE,
null, null, null, null, null, false, null, null, null, null,
- null, null, null, null, null, null, null, null, null,
+ null, 1, 1, null, null, null, null, null, null,
null, null, null, null, null, null);
return new OrderRequests.SaveRequest(
customerId, "当前企业", "SEA", null, "US", null, "目的仓", null, null,
@@ -722,6 +776,25 @@ class OrderApplicationServiceStateTest {
amazonWarehouseCode, shipmentId, referenceId);
}
+ private OrderRequests.SaveRequest requestWithCargoQuantities(int packageCount, Integer unitsPerPackage,
+ Integer totalQuantity) {
+ OrderRequests.SaveRequest source = request();
+ var cargo = new OrderRequests.Cargo(
+ "货物", null, "CARTON", packageCount, BigDecimal.ONE, BigDecimal.ONE,
+ null, null, null, null, null, false, null, null, null, null,
+ null, unitsPerPackage, totalQuantity, null, null, null, null, null, null,
+ null, null, null, null, null, null);
+ return new OrderRequests.SaveRequest(
+ source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
+ source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
+ source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), List.of(cargo),
+ source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(),
+ source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(),
+ source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(),
+ source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
+ source.referenceId());
+ }
+
private OrderMasterDataGuard.Snapshots snapshots() {
return snapshots(null);
}
@@ -758,6 +831,8 @@ class OrderApplicationServiceStateTest {
cargo.setPackageCount(1);
cargo.setGrossWeightKg(BigDecimal.ONE);
cargo.setVolumeCbm(BigDecimal.ONE);
+ cargo.setUnitsPerPackage(1);
+ cargo.setTotalQuantity(1);
return cargo;
}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java b/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
new file mode 100644
index 000000000..e05da0868
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/service/OrderCargoPackageCountsTest.java
@@ -0,0 +1,29 @@
+package com.baiyue.order.service;
+
+import com.baiyue.order.entity.OrderCargoEntity;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OrderCargoPackageCountsTest {
+ @Test
+ void sameMixedCartonGroupCountsPhysicalCartonsOnce() {
+ OrderCargoEntity skuA = cargo(10, "A");
+ OrderCargoEntity skuB = cargo(10, "A");
+ OrderCargoEntity separate = cargo(4, null);
+
+ assertThat(OrderCargoPackageCounts.physical(
+ List.of(skuA, skuB, separate), "ONE_CLICK_DROPSHIP")).isEqualTo(14);
+ assertThat(OrderCargoPackageCounts.physical(
+ List.of(skuA, skuB, separate), "STANDARD")).isEqualTo(24);
+ }
+
+ private OrderCargoEntity cargo(int count, String group) {
+ OrderCargoEntity value = new OrderCargoEntity();
+ value.setPackageCount(count);
+ value.setPackingGroupNo(group);
+ return value;
+ }
+}
diff --git a/print/AGENTS.md b/print/AGENTS.md
index 2542e3f2e..7f87e386e 100644
--- a/print/AGENTS.md
+++ b/print/AGENTS.md
@@ -4,7 +4,7 @@
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
-- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(均为 `210mm x 297mm`);新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
+- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V1`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板每托固定一页,展示托盘 Code 128 一维码、最多两行入仓码和最多 34 行完整 SKU 归托信息,不展示作业状态。新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
## 快速索引
@@ -13,10 +13,10 @@
| 调用方接入指南 | `README.md` |
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
-| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) |
+| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) |
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
-| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java`、`dto/DispatchBolPdfDtos.java` |
-| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/DispatchBolRenderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
+| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/DropshipPalletA4PdfDtos.java`、`dto/FreightA4PdfDtos.java`、`dto/DispatchBolPdfDtos.java` |
+| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/DropshipPalletA4Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/DispatchBolRenderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.java`、`service/DispatchBolPdfService.java`、`service/PdfGenerationLimiter.java` |
| PDF 参数配置 | `config/PrintPdfProperties.java` |
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
@@ -32,4 +32,4 @@
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
-- 修改后至少从父目录运行 `mvn -pl print -am test`;渲染变更同时核对页面尺寸、页数、页序、二维码反向解码和字体嵌入。
+- 修改后至少从父目录运行 `mvn -pl print -am test`;渲染变更同时核对页面尺寸、页数、页序、二维码或一维码反向解码和字体嵌入。
diff --git a/print/README.md b/print/README.md
index 990fbd657..4f45ee9af 100644
--- a/print/README.md
+++ b/print/README.md
@@ -8,6 +8,7 @@
- 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
+- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V1`,每托一页 A4,包含托盘 Code 128 一维码、最多两行入仓码、总箱数和最多 34 行 SKU/商品名称/箱数/实点数量,不展示作业状态。
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。
- 本地直连:`http://127.0.0.1:9207`。
@@ -263,7 +264,7 @@ spring:
1. `GET /actuator/health` 返回 `UP`。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
-3. 检查入仓标签页面为严格 `100mm x 100mm`,A4 单据页面为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。
+3. 检查入仓标签页面为严格 `100mm x 100mm`,一件代发托盘信息单及其他 A4 单据为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。
4. 提交多页并检查页序与数组顺序一致。
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。
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 11f9b2fa2..d185bd58f 100644
--- a/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
+++ b/print/src/main/java/com/baiyue/print/controller/InternalPdfController.java
@@ -5,9 +5,11 @@ import jakarta.annotation.Resource;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.DispatchBolPdfDtos;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.DispatchBolPdfService;
+import com.baiyue.print.service.DropshipPalletA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@@ -33,6 +35,7 @@ public class InternalPdfController {
@Resource private InboundMarkPdfService pdfService;
@Resource private FreightA4PdfService freightA4PdfService;
@Resource private DispatchBolPdfService dispatchBolPdfService;
+ @Resource private DropshipPalletA4PdfService dropshipPalletA4PdfService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -101,4 +104,17 @@ public class InternalPdfController {
.header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
}
+
+ @PostMapping(value = "/dropship-pallet-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity generateDropshipPalletA4Documents(
+ @RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
+ @Valid @RequestBody DropshipPalletA4PdfDtos.Request request) {
+ tokenGuard.verify(internalToken);
+ DropshipPalletA4PdfService.GeneratedPdf pdf = dropshipPalletA4PdfService.generate(request);
+ byte[] content = pdf.content();
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache")
+ .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString())
+ .header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
+ .contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
+ }
}
diff --git a/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java b/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java
new file mode 100644
index 000000000..627e8ed85
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/dto/DropshipPalletA4PdfDtos.java
@@ -0,0 +1,23 @@
+package com.baiyue.print.dto;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/** 一件代发托盘 A4 信息单的内部渲染契约。 */
+public final class DropshipPalletA4PdfDtos {
+ public static final String TEMPLATE = "DROPSHIP_PALLET_A4_V1";
+
+ private DropshipPalletA4PdfDtos() {}
+
+ public record Request(String templateCode, PageSpec pageSpec, String downloadFileName,
+ List documents) {}
+
+ public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
+
+ public record Document(String palletNo, String inboundCodes, int cartonCount,
+ int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
+ String barcodeContent, List skuRows) {}
+
+ public record SkuRow(String inboundCode, String skuCode, String cargoName,
+ int cartonCount, int skuQuantity) {}
+}
diff --git a/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java
new file mode 100644
index 000000000..5cd8c08f4
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/renderer/DropshipPalletA4Renderer.java
@@ -0,0 +1,167 @@
+package com.baiyue.print.renderer;
+
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
+import com.google.zxing.common.BitMatrix;
+import jakarta.annotation.Resource;
+import java.awt.Color;
+import java.io.IOException;
+import java.util.List;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.springframework.stereotype.Component;
+
+/** 每个托盘一页 A4,Code 128 托盘码与完整 SKU 归托信息同页展示。 */
+@Component
+public class DropshipPalletA4Renderer {
+ private static final float[] COLS = {8, 18, 57, 98, 161, 181, 202};
+ private static final String[] HEADERS = {"序号", "入仓码", "SKU", "商品名称", "箱数", "实点数量"};
+ private static final double TABLE_TOP = 105;
+ private static final double HEADER_HEIGHT = 8;
+ private static final double REGULAR_ROW_HEIGHT = 7;
+ private static final double DENSE_ROW_HEIGHT = 5.2;
+ private static final float BARCODE_WIDTH_PT = PdfUnits.mmToPt(158);
+ private static final float BARCODE_HEIGHT_PT = PdfUnits.mmToPt(22);
+ @Resource private PdfBarcodeRenderer barcodeRenderer;
+
+ public void renderPage(PDDocument pdf, PDFont font, DropshipPalletA4PdfDtos.Document data,
+ BitMatrix barcodeMatrix) throws IOException {
+ PDPage page = new PDPage(PDRectangle.A4);
+ pdf.addPage(page);
+ PdfUnits units = new PdfUnits(297);
+ try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
+ stream.setNonStrokingColor(Color.BLACK);
+ stream.setLineWidth(PdfUnits.mmToPt(.3));
+ centered(stream, units, font, "一件代发托盘信息单", 15, 105, 13);
+ centered(stream, units, font, data.palletNo(), 22, 105, 25);
+ barcode(stream, units, barcodeMatrix);
+ fill(stream, units, new Color(245, 247, 250), 8, 58, 202, 94);
+ inboundCodes(stream, units, font, data.inboundCodes());
+ line(stream, units, 8, 78, 202, 78);
+ metric(stream, units, font, "总箱数", Integer.toString(data.cartonCount()), 8, 56.5);
+ metric(stream, units, font, "SKU 数", Integer.toString(data.skuCount()), 56.5, 105);
+ metric(stream, units, font, "重量", decimal(data.weightKg()) + " kg", 105, 153.5);
+ metric(stream, units, font, "体积", decimal(data.volumeCbm()) + " m³", 153.5, 202);
+ line(stream, units, 56.5, 78, 56.5, 94);
+ line(stream, units, 105, 78, 105, 94);
+ line(stream, units, 153.5, 78, 153.5, 94);
+ line(stream, units, 8, 99, 202, 99);
+ text(stream, units, font, "SKU 明细", 11, 8, 104);
+ table(stream, units, font, data.skuRows());
+ }
+ }
+
+ private void barcode(PDPageContentStream stream, PdfUnits units, BitMatrix matrix) throws IOException {
+ float left = PdfUnits.mmToPt(26);
+ float bottom = units.topDownMmToPdfY(54);
+ barcodeRenderer.draw(stream, matrix, left, bottom, BARCODE_WIDTH_PT, BARCODE_HEIGHT_PT);
+ }
+
+ private void table(PDPageContentStream stream, PdfUnits units, PDFont font,
+ List rows) throws IOException {
+ int visibleRows = Math.max(1, rows.size());
+ double rowHeight = rows.size() > 24 ? DENSE_ROW_HEIGHT : REGULAR_ROW_HEIGHT;
+ float rowFontSize = rows.size() > 24 ? 5.3f : 6.4f;
+ double headerBottom = TABLE_TOP + HEADER_HEIGHT;
+ double bottom = headerBottom + visibleRows * rowHeight;
+ fill(stream, units, new Color(235, 239, 245), COLS[0], TABLE_TOP, COLS[COLS.length - 1], headerBottom);
+ for (float x : COLS) line(stream, units, x, TABLE_TOP, x, bottom);
+ for (int boundary = 0; boundary <= visibleRows; boundary++) {
+ line(stream, units, COLS[0], headerBottom + boundary * rowHeight,
+ COLS[COLS.length - 1], headerBottom + boundary * rowHeight);
+ }
+ line(stream, units, COLS[0], TABLE_TOP, COLS[COLS.length - 1], TABLE_TOP);
+ for (int column = 0; column < HEADERS.length; column++) {
+ centeredCell(stream, units, font, HEADERS[column], 7.2f,
+ COLS[column] + .5, COLS[column + 1] - .5, TABLE_TOP + 5.5);
+ }
+ if (rows.isEmpty()) {
+ centeredCell(stream, units, font, "暂无已归托 SKU", 7.2f, COLS[0], COLS[COLS.length - 1], headerBottom + 4.8);
+ return;
+ }
+ for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
+ DropshipPalletA4PdfDtos.SkuRow row = rows.get(rowIndex);
+ String[] values = {Integer.toString(rowIndex + 1), row.inboundCode(), row.skuCode(), row.cargoName(),
+ Integer.toString(row.cartonCount()), Integer.toString(row.skuQuantity())};
+ double baseline = headerBottom + rowIndex * rowHeight + rowHeight * .7;
+ for (int column = 0; column < values.length; column++) {
+ centeredCell(stream, units, font, values[column], rowFontSize,
+ COLS[column] + .7, COLS[column + 1] - .7, baseline);
+ }
+ }
+ }
+
+ private String decimal(java.math.BigDecimal value) {
+ return value == null ? "0" : value.stripTrailingZeros().toPlainString();
+ }
+ private void inboundCodes(PDPageContentStream s, PdfUnits u, PDFont f, String value) throws IOException {
+ text(s, u, f, "入仓码:", 8, 14, 66);
+ List lines = wrap(f, value, 8, PdfUnits.mmToPt(163), 2);
+ for (int index = 0; index < lines.size(); index++) {
+ text(s, u, f, lines.get(index), 8, 33, 66 + index * 6);
+ }
+ }
+ private void metric(PDPageContentStream s, PdfUnits u, PDFont f, String label, String value,
+ double x1, double x2) throws IOException {
+ centeredCell(s, u, f, label, 7.2f, x1 + 1, x2 - 1, 84);
+ centeredCell(s, u, f, value, 10.5f, x1 + 1, x2 - 1, 91);
+ }
+ private void centered(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
+ double centerX, double y) throws IOException {
+ float width = f.getStringWidth(value) / 1000 * size;
+ text(s, u, f, value, size, centerX - width / PdfUnits.mmToPt(1) / 2, y);
+ }
+ private void centeredCell(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
+ double x1, double x2, double y) throws IOException {
+ String fitted = fit(f, value, size, PdfUnits.mmToPt(x2 - x1));
+ float width = f.getStringWidth(fitted) / 1000 * size;
+ text(s, u, f, fitted, size, x1 + (x2 - x1 - width / PdfUnits.mmToPt(1)) / 2, y);
+ }
+ private String fit(PDFont font, String raw, float size, float maxWidth) throws IOException {
+ String value = raw == null || raw.isBlank() ? "—" : raw;
+ if (font.getStringWidth(value) / 1000 * size <= maxWidth) return value;
+ String ellipsis = "…";
+ int end = value.length();
+ while (end > 0 && font.getStringWidth(value.substring(0, end) + ellipsis) / 1000 * size > maxWidth) end--;
+ return end == 0 ? ellipsis : value.substring(0, end) + ellipsis;
+ }
+ private List wrap(PDFont font, String raw, float size, float maxWidth,
+ int maxLines) throws IOException {
+ String remaining = raw == null || raw.isBlank() ? "—" : raw;
+ List lines = new java.util.ArrayList<>();
+ while (!remaining.isEmpty() && lines.size() < maxLines) {
+ if (font.getStringWidth(remaining) / 1000 * size <= maxWidth) {
+ lines.add(remaining);
+ break;
+ }
+ int split = remaining.length();
+ while (split > 0 && font.getStringWidth(remaining.substring(0, split)) / 1000 * size > maxWidth) split--;
+ int delimiter = remaining.lastIndexOf('/', split - 1);
+ if (delimiter > 0) split = delimiter + 1;
+ String line = remaining.substring(0, Math.max(1, split));
+ remaining = remaining.substring(Math.max(1, split));
+ if (lines.size() == maxLines - 1 && !remaining.isEmpty()) line = fit(font, line + remaining, size, maxWidth);
+ lines.add(line);
+ }
+ return lines;
+ }
+ private void fill(PDPageContentStream s, PdfUnits u, Color color, double x1, double y1,
+ double x2, double y2) throws IOException {
+ s.setNonStrokingColor(color);
+ s.addRect(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y2), PdfUnits.mmToPt(x2 - x1), PdfUnits.mmToPt(y2 - y1));
+ s.fill();
+ s.setNonStrokingColor(Color.BLACK);
+ }
+ private void text(PDPageContentStream s, PdfUnits u, PDFont f, String value, float size,
+ double x, double y) throws IOException {
+ s.beginText(); s.setFont(f, size); s.newLineAtOffset(PdfUnits.mmToPt(x), u.topDownMmToPdfY(y));
+ s.showText(value == null ? "—" : value); s.endText();
+ }
+ private void line(PDPageContentStream s, PdfUnits u, double x1, double y1, double x2,
+ double y2) throws IOException {
+ s.moveTo(PdfUnits.mmToPt(x1), u.topDownMmToPdfY(y1));
+ s.lineTo(PdfUnits.mmToPt(x2), u.topDownMmToPdfY(y2)); s.stroke();
+ }
+}
diff --git a/print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java b/print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java
new file mode 100644
index 000000000..86470581e
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/renderer/PdfBarcodeRenderer.java
@@ -0,0 +1,36 @@
+package com.baiyue.print.renderer;
+
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.oned.Code128Writer;
+import java.io.IOException;
+import java.util.Map;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.springframework.stereotype.Component;
+
+/** 将 Code 128 一维码以矢量矩形绘制到 PDF,避免打印缩放造成模糊。 */
+@Component
+public class PdfBarcodeRenderer {
+ private static final int QUIET_ZONE_MODULES = 10;
+
+ public BitMatrix encode(String content) {
+ try {
+ return new Code128Writer().encode(content, BarcodeFormat.CODE_128, 1, 1,
+ Map.of(EncodeHintType.MARGIN, QUIET_ZONE_MODULES));
+ } catch (IllegalArgumentException exception) {
+ throw new IllegalArgumentException("barcodeContent cannot be encoded", exception);
+ }
+ }
+
+ public void draw(PDPageContentStream stream, BitMatrix matrix, float leftPt,
+ float bottomPt, float widthPt, float heightPt) throws IOException {
+ float moduleWidth = widthPt / matrix.getWidth();
+ for (int x = 0; x < matrix.getWidth(); x++) {
+ if (matrix.get(x, 0)) {
+ stream.addRect(leftPt + x * moduleWidth, bottomPt, moduleWidth + .02f, heightPt);
+ }
+ }
+ stream.fill();
+ }
+}
diff --git a/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java
new file mode 100644
index 000000000..b95a7d278
--- /dev/null
+++ b/print/src/main/java/com/baiyue/print/service/DropshipPalletA4PdfService.java
@@ -0,0 +1,100 @@
+package com.baiyue.print.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.print.config.PrintPdfProperties;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
+import com.baiyue.print.renderer.DropshipPalletA4Renderer;
+import com.baiyue.print.renderer.PdfBarcodeRenderer;
+import com.google.zxing.common.BitMatrix;
+import jakarta.annotation.Resource;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.text.Normalizer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Service;
+
+@Service
+public class DropshipPalletA4PdfService {
+ private static final BigDecimal A4_WIDTH = new BigDecimal("210");
+ private static final BigDecimal A4_HEIGHT = new BigDecimal("297");
+ private static final int MAX_SKU_ROWS = 34;
+ private static final Set FORBIDDEN_FILE_CHARS = Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
+
+ @Resource private PrintPdfProperties properties;
+ @Resource private PdfGenerationLimiter limiter;
+ @Resource private DropshipPalletA4Renderer renderer;
+ @Resource private PdfBarcodeRenderer barcodeRenderer;
+
+ public GeneratedPdf generate(DropshipPalletA4PdfDtos.Request request) {
+ DropshipPalletA4PdfDtos.Request normalized = normalize(request);
+ try (PdfGenerationLimiter.Permit ignored = limiter.acquire(); PDDocument pdf = new PDDocument();
+ InputStream fontStream = new ClassPathResource(properties.getFontResource()).getInputStream();
+ ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ PDType0Font font = PDType0Font.load(pdf, fontStream, true);
+ for (DropshipPalletA4PdfDtos.Document document : normalized.documents()) {
+ BitMatrix barcode = barcodeRenderer.encode(document.barcodeContent());
+ renderer.renderPage(pdf, font, document, barcode);
+ }
+ pdf.save(output);
+ byte[] content = output.toByteArray();
+ if (content.length > properties.getMaxOutputBytes()) invalid("生成的 PDF 超过输出大小上限");
+ return new GeneratedPdf(content, normalized.downloadFileName(), normalized.documents().size());
+ } catch (IOException exception) {
+ throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败");
+ }
+ }
+
+ private DropshipPalletA4PdfDtos.Request normalize(DropshipPalletA4PdfDtos.Request request) {
+ if (request == null || !DropshipPalletA4PdfDtos.TEMPLATE.equals(line(request.templateCode()))) invalid("templateCode is not supported");
+ if (request.pageSpec() == null || request.pageSpec().widthMm() == null || request.pageSpec().heightMm() == null
+ || request.pageSpec().widthMm().compareTo(A4_WIDTH) != 0 || request.pageSpec().heightMm().compareTo(A4_HEIGHT) != 0) invalid("pageSpec must be A4");
+ String fileName = line(request.downloadFileName());
+ if (fileName.isBlank() || !fileName.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf") || fileName.length() > 120
+ || fileName.chars().anyMatch(value -> FORBIDDEN_FILE_CHARS.contains((char) value))) invalid("downloadFileName is invalid");
+ if (request.documents() == null || request.documents().isEmpty() || request.documents().size() > properties.getMaxPages()) invalid("documents count is invalid");
+ List documents = new ArrayList<>();
+ for (int index = 0; index < request.documents().size(); index++) documents.add(normalizeDocument(request.documents().get(index), index));
+ return new DropshipPalletA4PdfDtos.Request(DropshipPalletA4PdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents));
+ }
+
+ private DropshipPalletA4PdfDtos.Document normalizeDocument(DropshipPalletA4PdfDtos.Document value, int index) {
+ if (value == null) invalid("documents[" + index + "] is required");
+ if (value.cartonCount() < 0 || value.skuCount() < 0 || negative(value.weightKg()) || negative(value.volumeCbm())) invalid("documents[" + index + "] totals are invalid");
+ if (value.skuRows() == null || value.skuRows().size() > MAX_SKU_ROWS) invalid("documents[" + index + "].skuRows exceeds the one-page A4 capacity");
+ List rows = new ArrayList<>();
+ for (DropshipPalletA4PdfDtos.SkuRow row : value.skuRows()) {
+ if (row == null || row.cartonCount() < 0 || row.skuQuantity() < 0) invalid("SKU row is invalid");
+ rows.add(new DropshipPalletA4PdfDtos.SkuRow(text(row.inboundCode(), 80), text(row.skuCode(), 128),
+ text(row.cargoName(), 200), row.cartonCount(), row.skuQuantity()));
+ }
+ return new DropshipPalletA4PdfDtos.Document(text(value.palletNo(), 80), text(value.inboundCodes(), 300),
+ value.cartonCount(), value.skuCount(), decimal(value.weightKg()), decimal(value.volumeCbm()),
+ barcode(value.barcodeContent()), List.copyOf(rows));
+ }
+
+ private BigDecimal decimal(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
+ private boolean negative(BigDecimal value) { return value != null && value.signum() < 0; }
+ private String barcode(String value) {
+ String normalized = text(value, 80);
+ if (normalized.codePoints().anyMatch(codePoint -> codePoint < 0x20 || codePoint > 0x7e)) invalid("barcodeContent must contain printable ASCII only");
+ return normalized;
+ }
+ private String text(String value, int maxCodePoints) {
+ String normalized = line(value);
+ if (normalized.isBlank() || normalized.codePointCount(0, normalized.length()) > maxCodePoints
+ || normalized.codePoints().anyMatch(Character::isISOControl)) invalid("document text is invalid");
+ return normalized;
+ }
+ private String line(String value) { return value == null ? "" : Normalizer.normalize(value, Normalizer.Form.NFC).strip(); }
+ private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); }
+
+ public record GeneratedPdf(byte[] content, String fileName, int pageCount) {}
+}
diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml
index b44e93c8c..d71e12cd5 100644
--- a/print/src/main/resources/openapi/print-internal-v1.yaml
+++ b/print/src/main/resources/openapi/print-internal-v1.yaml
@@ -118,6 +118,27 @@ paths:
content:
application/pdf: { schema: { type: string, format: binary } }
application/json: { schema: { $ref: '#/components/schemas/ApiError' } }
+ /internal/print/v1/pdfs/dropship-pallet-a4-documents:
+ post:
+ summary: Generate one A4 information sheet per dropship pallet
+ operationId: generateDropshipPalletA4Pdf
+ security: [{ internalServiceToken: [] }]
+ parameters: [{ $ref: '#/components/parameters/RequestId' }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: '#/components/schemas/DropshipPalletA4PdfRequest' } } }
+ responses:
+ '200':
+ description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
+ headers:
+ Content-Disposition: { schema: { type: string } }
+ X-Print-Page-Count: { schema: { type: integer, minimum: 1, maximum: 2000 } }
+ content:
+ application/pdf: { schema: { type: string, format: binary } }
+ application/json: { schema: { $ref: '#/components/schemas/ApiError' } }
+ '413':
+ description: Request body exceeds the configured limit.
+ content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } }
components:
securitySchemes:
internalServiceToken:
@@ -254,6 +275,48 @@ components:
fbaShipmentId: { type: string }
poReferenceNo: { type: string }
packageCount: { type: integer, minimum: 0 }
+ DropshipPalletA4PdfRequest:
+ type: object
+ required: [templateCode, pageSpec, downloadFileName, documents]
+ properties:
+ templateCode: { type: string, enum: [DROPSHIP_PALLET_A4_V1] }
+ pageSpec: { $ref: '#/components/schemas/FreightA4PageSpec' }
+ downloadFileName: { type: string, maxLength: 120 }
+ documents:
+ type: array
+ minItems: 1
+ maxItems: 2000
+ description: One pallet renders as exactly one A4 page in request order.
+ items: { $ref: '#/components/schemas/DropshipPalletA4Document' }
+ DropshipPalletA4Document:
+ type: object
+ required: [palletNo, inboundCodes, cartonCount, skuCount, weightKg, volumeCbm, barcodeContent, skuRows]
+ properties:
+ palletNo: { type: string, maxLength: 80 }
+ inboundCodes: { type: string, maxLength: 300 }
+ cartonCount: { type: integer, minimum: 0 }
+ skuCount: { type: integer, minimum: 0 }
+ weightKg: { type: number, minimum: 0 }
+ volumeCbm: { type: number, minimum: 0 }
+ barcodeContent:
+ type: string
+ maxLength: 80
+ pattern: '^[\\x20-\\x7E]+$'
+ description: Code 128 一维码内容,当前固定为稳定托盘码
+ skuRows:
+ type: array
+ minItems: 0
+ maxItems: 34
+ items: { $ref: '#/components/schemas/DropshipPalletSkuRow' }
+ DropshipPalletSkuRow:
+ type: object
+ required: [inboundCode, skuCode, cargoName, cartonCount, skuQuantity]
+ properties:
+ inboundCode: { type: string, maxLength: 80 }
+ skuCode: { type: string, maxLength: 128 }
+ cargoName: { type: string, maxLength: 200 }
+ cartonCount: { type: integer, minimum: 0 }
+ skuQuantity: { type: integer, minimum: 0 }
FreightA4PageSpec:
type: object
required: [widthMm, heightMm]
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 9c059f7d9..798c269f4 100644
--- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java
+++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java
@@ -4,9 +4,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.print.dto.FreightA4PdfDtos;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.DispatchBolPdfService;
+import com.baiyue.print.service.DropshipPalletA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
@@ -45,6 +47,8 @@ class InternalPdfControllerTest {
private FreightA4PdfService freightA4PdfService;
@MockBean
private DispatchBolPdfService dispatchBolPdfService;
+ @MockBean
+ private DropshipPalletA4PdfService dropshipPalletA4PdfService;
@Resource
private InternalPdfController controller;
private MockMvc mockMvc;
@@ -169,6 +173,27 @@ class InternalPdfControllerTest {
verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class));
}
+ @Test
+ void returnsDropshipPalletA4PdfWithDownloadHeaders() throws Exception {
+ byte[] pdf = "%PDF-pallet-a4".getBytes(StandardCharsets.US_ASCII);
+ when(dropshipPalletA4PdfService.generate(any())).thenReturn(
+ new DropshipPalletA4PdfService.GeneratedPdf(pdf, "DP-260911-0034-A4托盘信息单.pdf", 1));
+
+ mockMvc.perform(post("/internal/print/v1/pdfs/dropship-pallet-a4-documents")
+ .header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
+ .contentType(MediaType.APPLICATION_JSON)
+ .accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
+ .content(validDropshipPalletA4RequestJson()))
+ .andExpect(status().isOk())
+ .andExpect(content().contentType(MediaType.APPLICATION_PDF))
+ .andExpect(content().bytes(pdf))
+ .andExpect(header().string("X-Print-Page-Count", "1"))
+ .andExpect(header().string("Content-Disposition",
+ org.hamcrest.Matchers.containsString("filename*=")));
+
+ verify(dropshipPalletA4PdfService).generate(any(DropshipPalletA4PdfDtos.Request.class));
+ }
+
@Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class
@@ -185,11 +210,13 @@ class InternalPdfControllerTest {
assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
+ .contains("/internal/print/v1/pdfs/dropship-pallet-a4-documents:")
.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("DROPSHIP_PALLET_A4_V1")
.contains("additionalProperties: false")
.contains("minItems: 1")
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
@@ -238,4 +265,30 @@ class InternalPdfControllerTest {
""";
}
+ private String validDropshipPalletA4RequestJson() {
+ return """
+ {
+ "templateCode": "DROPSHIP_PALLET_A4_V1",
+ "pageSpec": {"widthMm": 210, "heightMm": 297},
+ "downloadFileName": "DP-260911-0034-A4托盘信息单.pdf",
+ "documents": [{
+ "palletNo": "DP-260911-0034",
+ "inboundCodes": "NYEXX2609110249",
+ "cartonCount": 3,
+ "skuCount": 1,
+ "weightKg": 90,
+ "volumeCbm": 0.534,
+ "barcodeContent": "DP-260911-0034",
+ "skuRows": [{
+ "inboundCode": "NYEXX2609110249",
+ "skuCode": "SKU-001",
+ "cargoName": "蓝牙音箱",
+ "cartonCount": 3,
+ "skuQuantity": 90
+ }]
+ }]
+ }
+ """;
+ }
+
}
diff --git a/print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java b/print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java
new file mode 100644
index 000000000..dfce28def
--- /dev/null
+++ b/print/src/test/java/com/baiyue/print/renderer/PdfBarcodeRendererTest.java
@@ -0,0 +1,22 @@
+package com.baiyue.print.renderer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.zxing.common.BitMatrix;
+import org.junit.jupiter.api.Test;
+
+class PdfBarcodeRendererTest {
+ @Test
+ void encodesCode128DeterministicallyWithQuietZone() {
+ PdfBarcodeRenderer renderer = new PdfBarcodeRenderer();
+
+ BitMatrix first = renderer.encode("DP-260911-0034");
+ BitMatrix second = renderer.encode("DP-260911-0034");
+
+ assertThat(first).isEqualTo(second);
+ assertThat(first.getHeight()).isEqualTo(1);
+ assertThat(first.getWidth()).isGreaterThan(100);
+ assertThat(first.getEnclosingRectangle()).isNotNull();
+ assertThat(first.getEnclosingRectangle()[0]).isGreaterThanOrEqualTo(5);
+ }
+}
diff --git a/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java
new file mode 100644
index 000000000..3b7076e93
--- /dev/null
+++ b/print/src/test/java/com/baiyue/print/service/DropshipPalletA4PdfServiceGenerationTest.java
@@ -0,0 +1,93 @@
+package com.baiyue.print.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.print.config.PrintPdfProperties;
+import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
+import com.baiyue.print.renderer.DropshipPalletA4Renderer;
+import com.baiyue.print.renderer.PdfBarcodeRenderer;
+import com.baiyue.print.renderer.PdfUnits;
+import jakarta.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+@SpringJUnitConfig(classes = {DropshipPalletA4PdfService.class, PrintPdfProperties.class,
+ PdfGenerationLimiter.class, DropshipPalletA4Renderer.class, PdfBarcodeRenderer.class})
+class DropshipPalletA4PdfServiceGenerationTest {
+ @Resource private DropshipPalletA4PdfService service;
+
+ @Test
+ void generatesOneA4PageWithPalletBarcodeAndSkuDetails() throws Exception {
+ DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
+ "DP-260911-0034", "NYEXX2609110249", 3, 2,
+ new BigDecimal("90"), new BigDecimal("0.534"), "DP-260911-0034",
+ List.of(
+ new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-001", "蓝牙音箱", 2, 40),
+ new DropshipPalletA4PdfDtos.SkuRow("NYEXX2609110249", "SKU-002", "充电线", 1, 50)));
+ var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
+ DropshipPalletA4PdfDtos.TEMPLATE,
+ new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
+ "DP-260911-0034-A4托盘信息单.pdf", List.of(document)));
+ assertThat(generated.pageCount()).isEqualTo(1);
+ assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
+ try (PDDocument pdf = Loader.loadPDF(generated.content())) {
+ assertThat(pdf.getNumberOfPages()).isEqualTo(1);
+ assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
+ assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
+ assertThat(new PDFTextStripper().getText(pdf))
+ .contains("DP-260911-0034", "NYEXX2609110249", "SKU 明细", "SKU-001", "SKU-002", "40", "50")
+ .containsOnlyOnce("DP-260911-0034")
+ .doesNotContain("状态", "待封托", "已封托");
+ }
+ }
+
+ @Test
+ void keepsTenInboundCodesAndThirtyTwoSkuRowsOnOneA4Page() throws Exception {
+ List inboundCodes = new ArrayList<>();
+ for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index));
+ List rows = new ArrayList<>();
+ for (int index = 1; index <= 32; index++) {
+ rows.add(new DropshipPalletA4PdfDtos.SkuRow(inboundCodes.get((index - 1) % inboundCodes.size()),
+ "SKU-" + String.format("%03d", index), "测试商品" + index, 2, index * 5));
+ }
+ DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
+ "DP-260911-0034", String.join("/", inboundCodes), 64, 32,
+ new BigDecimal("860.5"), new BigDecimal("6.728"), "DP-260911-0034", rows);
+ var generated = service.generate(new DropshipPalletA4PdfDtos.Request(
+ DropshipPalletA4PdfDtos.TEMPLATE,
+ new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
+ "DP-260911-0034-高密度预览.pdf", List.of(document)));
+ try (PDDocument pdf = Loader.loadPDF(generated.content())) {
+ assertThat(pdf.getNumberOfPages()).isEqualTo(1);
+ assertThat(new PDFTextStripper().getText(pdf))
+ .contains("NYEXX2609110241", "NYEXX2609110250", "SKU-001", "SKU-032")
+ .containsOnlyOnce("DP-260911-0034");
+ }
+ }
+
+ @Test
+ void rejectsSkuRowsThatCannotFitOnOneA4Page() {
+ List rows = new ArrayList<>();
+ for (int index = 0; index < 35; index++) {
+ rows.add(new DropshipPalletA4PdfDtos.SkuRow("INBOUND", "SKU-" + index, "商品", 1, 1));
+ }
+ DropshipPalletA4PdfDtos.Document document = new DropshipPalletA4PdfDtos.Document(
+ "DP-260911-0034", "INBOUND", 35, 35,
+ BigDecimal.ZERO, BigDecimal.ZERO, "DP-260911-0034", rows);
+
+ assertThatThrownBy(() -> service.generate(new DropshipPalletA4PdfDtos.Request(
+ DropshipPalletA4PdfDtos.TEMPLATE,
+ new DropshipPalletA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")),
+ "pallet.pdf", List.of(document))))
+ .isInstanceOf(BizException.class)
+ .hasMessageContaining("one-page A4 capacity");
+ }
+}