feat: support mixed-SKU dropship cartons

This commit is contained in:
2026-09-11 15:38:19 +08:00
parent 0db11517db
commit cc7c2f0954
42 changed files with 1263 additions and 128 deletions
@@ -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;
+3 -3
View File
@@ -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`、数据库快照字段和对应契约测试。
@@ -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);
}
}
}
@@ -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<byte[]> 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<Label> labels) {
}
@@ -44,4 +50,14 @@ public interface PrintPdfClient {
record CargoRow(String marksAndNos, String quantity, String packaging,
String descriptionOfGoods, String grossWeight, String measurement) {}
record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec,
String downloadFileName, List<DropshipPalletDocument> documents) {}
record DropshipPalletDocument(String palletNo, String inboundCodes, int cartonCount,
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
String barcodeContent, List<DropshipPalletSkuRow> skuRows) {}
record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName,
int cartonCount, int skuQuantity) {}
}
@@ -4,7 +4,10 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.List;
@@ -19,12 +22,16 @@ public final class DropshipPalletizationDtos {
public record CountCartonRequest(@Positive long warehouseId,
@NotBlank @Size(max = 80) String boxCode,
@Positive int actualSkuQuantity,
@NotNull DiscrepancyAction discrepancyAction,
@Size(max = 500) String discrepancyRemark,
@NotEmpty @Valid List<CountSkuLine> skuLines,
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
public record CountSkuLine(@Positive long inboundDetailId,
@NotBlank @Size(max = 128) String skuCode,
@PositiveOrZero int actualQuantity,
@NotNull DiscrepancyAction discrepancyAction,
@Size(max = 500) String discrepancyRemark) {}
public record BindPendingRequest(@Positive long warehouseId,
@NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
@NotBlank @Size(max = 128) String operationId) {}
@@ -63,12 +70,14 @@ public final class DropshipPalletizationDtos {
List<String> cartonMarks) {}
public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode,
Long inboundDetailId, String skuCode, String cargoName,
Integer expectedSkuQuantity, Integer actualSkuQuantity,
String discrepancyStatus, String discrepancyRemark,
List<CartonSkuLine> skuLines,
String countStatus, Long palletId, String palletNo,
Boolean canCount, String blockReason) {}
public record CartonSkuLine(Long inboundDetailId, String skuCode, String cargoName,
Integer expectedQuantity, Integer actualQuantity,
String discrepancyStatus, String discrepancyRemark) {}
public record CountResult(Carton carton, Integer countedCartons, Integer boundCartons,
Integer unboundCartons, Integer remainingCartons, Pallet pallet) {}
}
@@ -82,6 +82,12 @@ public interface DropshipPalletizationMapper {
FROM fl_inbound_carton_label l JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'""")
LabelRow label(@Param("taskId") long taskId,@Param("boxCode") String boxCode);
@Select("""
SELECT s.inbound_detail_id,s.sku_code,s.cargo_name,s.expected_quantity,s.line_no
FROM fl_inbound_carton_label_sku s JOIN fl_inbound_carton_label l ON l.id=s.carton_label_id
JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
WHERE t.id=#{taskId} AND l.id=#{labelId} ORDER BY s.line_no
""") List<LabelSkuRow> labelSkus(@Param("taskId") long taskId,@Param("labelId") long labelId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.count_operation_id=#{operationId}") CartonRow cartonByCountOperation(@Param("operationId") String operationId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.id=#{cartonId}") CartonRow carton(@Param("taskId") long taskId,@Param("cartonId") long cartonId);
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.carton_label_id=#{labelId}")
@@ -103,6 +109,24 @@ public interface DropshipPalletizationMapper {
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Insert("""
INSERT INTO fl_dropship_pallet_carton_sku
(id,carton_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,actual_quantity,
discrepancy_status,discrepancy_remark,line_no)
VALUES(#{id},#{cartonId},#{detailId},#{skuCode},#{cargoName},#{expectedQuantity},#{actualQuantity},
#{discrepancyStatus},#{discrepancyRemark},#{lineNo})
""")
int insertCartonSku(@Param("id") long id,@Param("cartonId") long cartonId,@Param("detailId") long detailId,
@Param("skuCode") String skuCode,@Param("cargoName") String cargoName,
@Param("expectedQuantity") int expectedQuantity,@Param("actualQuantity") int actualQuantity,
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
@Param("lineNo") int lineNo);
@Select("""
SELECT inbound_detail_id,sku_code,cargo_name,expected_quantity,actual_quantity,discrepancy_status,discrepancy_remark,line_no
FROM fl_dropship_pallet_carton_sku WHERE carton_id=#{cartonId} ORDER BY line_no
""") List<CartonSkuRow> cartonSkus(@Param("cartonId") long cartonId);
@Update("""
UPDATE fl_dropship_pallet_task SET counted_cartons=counted_cartons+1,allocated_cartons=allocated_cartons+#{boundCount},
discrepancy_cartons=discrepancy_cartons+#{discrepancyCount},version=version+1,updated_at=CURRENT_TIMESTAMP(3)
@@ -170,8 +194,9 @@ public interface DropshipPalletizationMapper {
WHERE c.pallet_id=#{palletId}""")
PalletMetrics metrics(@Param("palletId") long palletId);
@Select("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count,
COUNT(DISTINCT c.sku_code) sku_count,
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count,
(SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc
JOIN fl_dropship_pallet_carton_sku sku ON sku.carton_id=pc.id WHERE pc.pallet_id=p.id) sku_count,
COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg,
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
@@ -186,23 +211,26 @@ public interface DropshipPalletizationMapper {
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,c.tenant_id account_id,c.inbound_id,o.order_id,o.order_no,
o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,
o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count,
SUM(c.actual_sku_quantity) actual_sku_quantity,
s.inbound_detail_id,s.sku_code,MAX(s.cargo_name) cargo_name,COUNT(DISTINCT c.id) carton_count,
SUM(s.actual_quantity) actual_sku_quantity,
COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg,
COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg,
COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
JOIN fl_dropship_pallet_carton_sku s ON s.carton_id=c.id
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id WHERE p.id=#{palletId}
GROUP BY p.id,p.pallet_no,p.pallet_status,c.tenant_id,c.inbound_id,o.order_id,o.order_no,o.inbound_code,
o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name,
o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
ORDER BY c.inbound_detail_id
o.customer_name,s.inbound_detail_id,s.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
ORDER BY s.inbound_detail_id
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
@Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
@Getter @Setter class LabelSkuRow { private Long inboundDetailId; private String skuCode,cargoName; private Integer expectedQuantity,lineNo; }
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
@Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; }
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
@Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.mapper;
import lombok.Getter;
import lombok.Setter;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** A physical carton label may freeze more than one SKU line. */
@Mapper
public interface InboundCartonLabelSkuMapper {
@Insert("""
INSERT INTO fl_inbound_carton_label_sku
(id,tenant_id,inbound_id,carton_label_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no,created_at)
VALUES(#{id},#{tenantId},#{inboundId},#{labelId},#{detailId},#{skuCode},#{cargoName},#{expectedQuantity},#{lineNo},CURRENT_TIMESTAMP(3))
""")
int insert(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("inboundId") long inboundId, @Param("labelId") long labelId,
@Param("detailId") long detailId, @Param("skuCode") String skuCode,
@Param("cargoName") String cargoName, @Param("expectedQuantity") int expectedQuantity,
@Param("lineNo") int lineNo);
@Select("""
SELECT inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no
FROM fl_inbound_carton_label_sku WHERE carton_label_id=#{labelId} ORDER BY line_no
""")
List<SkuRow> findByLabel(@Param("labelId") long labelId);
@Select("""
SELECT carton_label_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no
FROM fl_inbound_carton_label_sku WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
ORDER BY carton_label_id,line_no
""")
List<SkuRow> findByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Getter @Setter
class SkuRow {
private Long inboundDetailId;
private Long cartonLabelId;
private String skuCode;
private String cargoName;
private Integer expectedQuantity;
private Integer lineNo;
}
}
@@ -62,8 +62,10 @@ public class DropshipPalletizationService {
else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员";
else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态";
else if(!palletSetupReady(task)) block=palletSetupMessage(task);
return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(),
label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null,
List<CartonSkuLine> skuLines=labelSkuLines(taskId,label).stream().map(line ->
new CartonSkuLine(line.getInboundDetailId(),line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),
null,null,null)).toList();
return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),skuLines,"UNCOUNTED",null,null,
block==null,block);
}
@@ -74,22 +76,22 @@ public class DropshipPalletizationService {
CartonRow replay=mapper.cartonByCountOperation(operation);
if(replay!=null){
if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim())
|| !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作");
|| !sameCountLines(mapper.cartonSkus(replay.getId()),request.skuLines())) throw conflict("清点幂等号已用于不同操作");
return countResult(task,replay);
}
LabelRow label=requireLabel(taskId,request.boxCode());
if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点");
int expected=requireExpectedQuantity(label);
boolean mismatch=expected!=request.actualSkuQuantity();
String remark=normalize(request.discrepancyRemark());
if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
List<CountedLine> lines=validateCountLines(labelSkuLines(taskId,label),request.skuLines());
boolean mismatch=lines.stream().anyMatch(CountedLine::mismatch);
PalletRow pallet=StringUtils.hasText(request.palletNo())?requireAssignablePallet(task,request.palletNo().trim()):null;
long cartonId=IdWorker.getId();
CountedLine primary=lines.get(0);
mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(),
label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(),
expected,request.actualSkuQuantity(),mismatch?"ACCEPTED_VARIANCE":"MATCH",remark,operation,operatorId,operatorName);
primary.detailId(),label.getCartonLabelId(),primary.skuCode(),primary.cargoName(),label.getBoxCode(),
primary.expected(),primary.actual(),mismatch?"ACCEPTED_VARIANCE":"MATCH",null,operation,operatorId,operatorName);
int lineNo=0;
for(CountedLine line:lines) mapper.insertCartonSku(IdWorker.getId(),cartonId,line.detailId(),line.skuCode(),line.cargoName(),
line.expected(),line.actual(),line.mismatch()?"ACCEPTED_VARIANCE":"MATCH",line.remark(),++lineNo);
if(mapper.addCounted(taskId,operatorId,pallet==null?0:1,mismatch?1:0)!=1) throw conflict("任务清点数量已变化,请刷新后重试");
TaskRow refreshed=requireTask(taskId,request.warehouseId());
return countResult(refreshed,mapper.carton(taskId,cartonId));
@@ -181,11 +183,14 @@ public class DropshipPalletizationService {
}
private Pallet palletView(PalletRow row,String inboundCode){
List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonRow>> groups=new LinkedHashMap<>();
for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundId()+"|"+c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),first.getInboundCode(),
first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(),
values.stream().map(CartonRow::getBoxCode).toList());}).toList();
List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonSkuWithBox>> groups=new LinkedHashMap<>();
for(CartonRow c:cartons) for(CartonSkuRow line:mapper.cartonSkus(c.getId()))
groups.computeIfAbsent(c.getInboundId()+"|"+line.getInboundDetailId()+"|"+line.getSkuCode(),ignored->new ArrayList<>())
.add(new CartonSkuWithBox(c,line));
List<SkuItem> items=groups.values().stream().map(values->{CartonSkuWithBox first=values.get(0);return new SkuItem(first.carton().getInboundId(),first.carton().getInboundCode(),
first.line().getInboundDetailId(),first.line().getSkuCode(),first.line().getCargoName(),values.size(),
values.stream().mapToInt(v->v.line().getActualQuantity()==null?0:v.line().getActualQuantity()).sum(),
values.stream().map(v->v.carton().getBoxCode()).toList());}).toList();
int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count();
PalletMetrics metrics=mapper.metrics(row.getId());
return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies,
@@ -194,12 +199,34 @@ public class DropshipPalletizationService {
}
private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){
return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(),
row.getExpectedSkuQuantity(),row.getActualSkuQuantity(),row.getDiscrepancyStatus(),row.getDiscrepancyRemark(),
List<CartonSkuLine> lines=mapper.cartonSkus(row.getId()).stream().map(line -> new CartonSkuLine(line.getInboundDetailId(),
line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),line.getActualQuantity(),line.getDiscrepancyStatus(),line.getDiscrepancyRemark())).toList();
return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,lines,
row.getPalletId()==null?"COUNTED_UNBOUND":"BOUND",row.getPalletId(),row.getPalletNo(),canCount,blockReason);
}
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(!StringUtils.hasText(label.getSkuCode())) throw conflict("箱码缺少SKU快照,不能清点"); requireExpectedQuantity(label); return label; }
private int requireExpectedQuantity(LabelRow label){ if(label.getUnitsPerCarton()==null||label.getUnitsPerCarton()<1) throw conflict("箱码缺少每箱SKU数量快照,不能清点"); return label.getUnitsPerCarton(); }
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(labelSkuLines(taskId,label).isEmpty()) throw conflict("箱码缺少SKU快照,不能清点"); return label; }
private List<LabelSkuRow> labelSkuLines(long taskId,LabelRow label){ return mapper.labelSkus(taskId,label.getCartonLabelId()); }
private List<CountedLine> validateCountLines(List<LabelSkuRow> expected,List<CountSkuLine> actual){
Map<Long,CountSkuLine> submitted=new LinkedHashMap<>();
for(CountSkuLine line:actual) if(submitted.put(line.inboundDetailId(),line)!=null) throw invalid("同一SKU明细不能重复清点");
if(submitted.size()!=expected.size()) throw invalid("必须清点当前箱内全部SKU");
List<CountedLine> result=new ArrayList<>();
for(LabelSkuRow line:expected){
CountSkuLine input=submitted.get(line.getInboundDetailId());
if(input==null || !Objects.equals(line.getSkuCode(),input.skuCode().trim())) throw invalid("清点SKU与箱唛不一致");
boolean mismatch=!Objects.equals(line.getExpectedQuantity(),input.actualQuantity()); String remark=normalize(input.discrepancyRemark());
if(!mismatch && input.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
if(mismatch && input.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
result.add(new CountedLine(line.getInboundDetailId(),line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),input.actualQuantity(),remark,mismatch));
}
return result;
}
private boolean sameCountLines(List<CartonSkuRow> saved,List<CountSkuLine> submitted){
if(saved.size()!=submitted.size()) return false;
Map<Long,Integer> quantities=new HashMap<>(); for(CountSkuLine line:submitted) quantities.put(line.inboundDetailId(),line.actualQuantity());
return saved.stream().allMatch(line->Objects.equals(line.getActualQuantity(),quantities.get(line.getInboundDetailId())));
}
private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); requirePalletSetup(task); return task; }
private void requirePalletSetup(TaskRow task){ if(!palletSetupReady(task)) throw conflict(palletSetupMessage(task)); }
private boolean palletSetupReady(TaskRow task){ return task.getExpectedPalletCount()!=null && task.getExpectedPalletCount()>0 && mapper.palletCount(task.getId())==task.getExpectedPalletCount(); }
@@ -211,4 +238,6 @@ public class DropshipPalletizationService {
private static String normalize(String v){return StringUtils.hasText(v)?v.trim():null;}
private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);}
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
private record CountedLine(long detailId,String skuCode,String cargoName,int expected,int actual,String remark,boolean mismatch){}
private record CartonSkuWithBox(CartonRow carton,CartonSkuRow line){}
}
@@ -25,6 +25,8 @@ import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
@Service
public class InboundCartonLabelPrintService {
private static final BigDecimal PAGE_MM = new BigDecimal("100");
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
private static final List<String> SAFE_RESPONSE_HEADERS = List.of(
HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.CACHE_CONTROL, HttpHeaders.PRAGMA,
"X-Print-Page-Count", "X-Request-Id", "X-Trace-Id");
@@ -113,7 +115,7 @@ public class InboundCartonLabelPrintService {
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
}
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
/** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) {
if (pallet == null || !StringUtils.hasText(pallet.palletNo())) {
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
@@ -121,22 +123,27 @@ public class InboundCartonLabelPrintService {
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode())
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/"));
if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode;
String skuSummary = pallet.items().stream()
.map(item -> item.skuCode() + "×" + item.cartonCount())
.collect(java.util.stream.Collectors.joining(" / "));
if (!StringUtils.hasText(skuSummary)) skuSummary = "待归托";
PrintPdfClient.Label label = new PrintPdfClient.Label(pallet.palletNo(), inboundCodes,
pallet.cartonCount() == 0 ? "CTNS:—" : "CTNS:" + pallet.cartonCount(), skuSummary, pallet.palletNo(), null);
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM),
pallet.palletNo() + "-托盘唛.pdf", List.of(label));
List<PrintPdfClient.DropshipPalletSkuRow> skuRows = pallet.items().stream()
.map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(),
item.cargoName(), number(item.cartonCount()), number(item.skuQuantity())))
.toList();
PrintPdfClient.DropshipPalletDocument document = new PrintPdfClient.DropshipPalletDocument(
pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()),
pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows);
PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest(
"DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM),
pallet.palletNo() + "-A4托盘信息单.pdf", List.of(document));
try {
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request));
} catch (FeignException | NoFallbackAvailableException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
}
}
private int number(Integer value) {
return value == null ? 0 : value;
}
private ResponseEntity<byte[]> generatePdf(String fileName, List<InboundCartonLabels.Label> labels,
String requestId) {
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.InboundCartonLabels;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import com.baiyue.firstleg.mapper.InboundCartonLabelSkuMapper;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
@@ -24,6 +25,8 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.LinkedHashMap;
import java.util.Map;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -36,6 +39,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("PREPARING", "WAIT_MEASURE", "MEASURING");
@Resource private InboundCartonLabelRepository repository;
@Resource private InboundCartonLabelSkuMapper labelSkus;
@Resource private OrderCargoClient orderCargoClient;
@Resource private PrintableOrderInboundClient printableOrderInboundClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -76,7 +80,8 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent);
return batch(inbound, consistent);
}
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
List<CargoGroup> groups = cartonGroups(cargoes, inbound.getFulfillmentType());
int total = groups.stream().mapToInt(CargoGroup::packageCount).sum();
if (total < 1 || total > MAX_LABELS) {
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
}
@@ -86,12 +91,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
int cartonIndex = 0;
LocalDateTime now = LocalDateTime.now();
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
int detailCount = requirePackageCount(cargo);
for (CargoGroup group : groups) {
OrderCargoClient.CargoLabelBinding cargo = group.cargoes().get(0);
int detailCount = group.packageCount();
for (int offset = 0; offset < detailCount; offset++) {
cartonIndex++;
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, fingerprint, now);
repository.insert(label);
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
int lineNo = 0;
for (OrderCargoClient.CargoLabelBinding line : group.cargoes()) {
labelSkus.insert(IdWorker.getId(), inbound.getTenantId(), inbound.getId(), label.getId(),
line.cargoId(), line.skuNo().trim(), line.cargoName(), line.unitsPerCarton(), ++lineNo);
}
}
existing.add(label);
}
}
@@ -195,6 +208,27 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
return cargo.packageCount();
}
private List<CargoGroup> cartonGroups(List<OrderCargoClient.CargoLabelBinding> cargoes, String fulfillmentType) {
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType)) {
return cargoes.stream().map(cargo -> new CargoGroup(null, requirePackageCount(cargo), List.of(cargo))).toList();
}
Map<String, List<OrderCargoClient.CargoLabelBinding>> grouped = new LinkedHashMap<>();
int single = 0;
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
requirePackageCount(cargo);
String group = StringUtils.hasText(cargo.packingGroupNo()) ? cargo.packingGroupNo().trim() : "__single_" + (++single);
grouped.computeIfAbsent(group, ignored -> new ArrayList<>()).add(cargo);
}
return grouped.entrySet().stream().map(entry -> {
List<OrderCargoClient.CargoLabelBinding> lines = entry.getValue();
int count = lines.get(0).packageCount();
if (lines.stream().anyMatch(line -> line.packageCount() != count)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "同一混箱组合的箱数必须一致:" + entry.getKey());
}
return new CargoGroup(entry.getKey(), count, List.copyOf(lines));
}).toList();
}
private void requirePrintableInboundCode(String inboundCode, int total) {
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
@@ -276,6 +310,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
String.valueOf(cargo.unitsPerCarton()),
cargo.packingGroupNo() == null ? "" : cargo.packingGroupNo().trim(),
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
.collect(java.util.stream.Collectors.joining("\n"));
try {
@@ -296,11 +331,16 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
private InboundCartonLabels.Batch batch(InboundOrderEntity inbound,
List<InboundCartonLabelEntity> entities) {
List<InboundCartonLabelSkuMapper.SkuRow> skuRows = labelSkus.findByInbound(inbound.getTenantId(), inbound.getId());
Map<Long, List<InboundCartonLabelSkuMapper.SkuRow>> skuRowsByLabel = skuRows == null ? Map.of()
: skuRows.stream().collect(java.util.stream.Collectors.groupingBy(
InboundCartonLabelSkuMapper.SkuRow::getCartonLabelId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
List<InboundCartonLabels.Label> labels = entities.stream().map(entity ->
new InboundCartonLabels.Label(entity.getId(), entity.getInboundDetailId(), entity.getCargoItemNo(),
entity.getCargoName(), entity.getDetailPackageCount(), entity.getBoxCode(),
entity.getTotalCartonCount(), entity.getCartonIndex(), entity.getBoxSpecification(),
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), entity.getSkuCode(), inbound.getInboundCode(),
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), displaySkuCodes(entity, skuRowsByLabel), inbound.getInboundCode(),
fallback(inbound.getWarehouseName(), "WAREHOUSE"),
"CTNS:" + entity.getTotalCartonCount() + "-" + entity.getCartonIndex(),
fallback(inbound.getChannelName(), fallback(inbound.getChannelCode(), "ROUTE"))))
@@ -314,9 +354,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
private String displaySkuCodes(InboundCartonLabelEntity label,
Map<Long, List<InboundCartonLabelSkuMapper.SkuRow>> skuRowsByLabel) {
List<InboundCartonLabelSkuMapper.SkuRow> lines = skuRowsByLabel.get(label.getId());
if (lines == null || lines.isEmpty()) return label.getSkuCode();
return lines.stream().map(InboundCartonLabelSkuMapper.SkuRow::getSkuCode)
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(" / "));
}
private void requireIds(long tenantId, long inboundId) {
if (tenantId <= 0 || inboundId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和入库单ID必须大于0");
}
}
private record CargoGroup(String groupNo, int packageCount,
List<OrderCargoClient.CargoLabelBinding> cargoes) {}
}
@@ -33,3 +33,19 @@ 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冻结快照';
@@ -230,6 +230,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 fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
@@ -1030,3 +1047,19 @@ 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清点事实';
@@ -67,15 +67,15 @@ paths:
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
get:
summary: PDA 打印预生成的一件代发托盘码
description: 测量审核通过并生成托盘码后即可打印,空托打印“待归托”。仅当前仓库、当前操作员可访问。返回 100mm × 100mm PDF。
summary: PDA 获取预生成托盘码的 A4 托盘信息单
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、入仓码、箱数以及已归托 SKU、商品名称和实点数量,不展示作业状态。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
x-required-authority: platform:firstleg:pda:view
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
- {$ref: '#/components/parameters/WarehouseId'}
responses:
'200': {description: 托盘码 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
'200': {description: A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
get:
summary: Admin 查询入仓单权威托盘结果
@@ -83,7 +83,7 @@ paths:
responses: {'200': {description: OK}}
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
get:
summary: 打印托盘码、入仓码和 SKU 实收汇总
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单
x-required-authority: firstleg:inbound:list
responses: {'200': {description: PDF}}
components:
@@ -114,18 +114,23 @@ components:
boxCode: {type: string}
inboundId: {type: integer, format: int64}
inboundCode: {type: string}
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string}
cargoName: {type: string, nullable: true}
expectedSkuQuantity: {type: integer, minimum: 1}
actualSkuQuantity: {type: integer, minimum: 1, nullable: true}
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
discrepancyRemark: {type: string, nullable: true}
skuLines: {type: array, minItems: 1, items: {$ref: '#/components/schemas/CartonSkuLine'}, description: 当前物理箱内全部SKU清点行}
countStatus: {type: string, enum: [UNCOUNTED, COUNTED_UNBOUND, BOUND]}
palletId: {type: integer, format: int64, nullable: true}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
canCount: {type: boolean}
blockReason: {type: string, nullable: true}
CartonSkuLine:
type: object
required: [inboundDetailId, skuCode, expectedQuantity]
properties:
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string}
cargoName: {type: string, nullable: true}
expectedQuantity: {type: integer, minimum: 1}
actualQuantity: {type: integer, minimum: 0, nullable: true}
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
discrepancyRemark: {type: string, nullable: true}
Pallet:
type: object
properties:
@@ -147,14 +152,23 @@ components:
properties: {warehouseId: {type: integer, format: int64}}
CountCartonRequest:
type: object
required: [warehouseId, boxCode, actualSkuQuantity, discrepancyAction, operationId]
required: [warehouseId, boxCode, skuLines, operationId]
properties:
warehouseId: {type: integer, format: int64}
boxCode: {type: string, maxLength: 80}
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
actualSkuQuantity: {type: integer, minimum: 1}
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
skuLines:
type: array
minItems: 1
items:
type: object
required: [inboundDetailId, skuCode, actualQuantity, discrepancyAction]
properties:
inboundDetailId: {type: integer, format: int64}
skuCode: {type: string, maxLength: 128}
actualQuantity: {type: integer, minimum: 0}
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
operationId: {type: string, maxLength: 128}
BindPendingRequest:
type: object
@@ -3,12 +3,15 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountCartonRequest;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.DiscrepancyAction;
import com.baiyue.firstleg.dto.DropshipPalletizationDtos.CountSkuLine;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelSkuRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonSkuRow;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -20,7 +23,7 @@ class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); }
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); }
@Test void reviewCreatesExpectedNumberOfPrintablePalletCodes(){
InboundOrderEntity inbound=dropshipInbound(); when(mapper.insertTask(anyLong(),eq(1L),eq(30L),eq(100L),eq(8))).thenReturn(1);
@@ -38,20 +41,21 @@ class DropshipPalletizationServiceTest {
@Test void resolvesUncountedCartonBeforeQuantityConfirmation(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var result=service.resolveCarton(10L,100L,"BOX-1",7L);
assertThat(result.inboundCode()).isEqualTo("IN-1"); assertThat(result.expectedSkuQuantity()).isEqualTo(12); assertThat(result.canCount()).isTrue();
assertThat(result.inboundCode()).isEqualTo("IN-1"); assertThat(result.skuLines()).extracting("expectedQuantity").containsExactly(12); assertThat(result.canCount()).isTrue();
}
@Test void cartonCanBindDirectlyToAPreGeneratedPallet(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label()); when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet());
when(mapper.addCounted(10L,7L,1,0)).thenReturn(1); when(mapper.carton(eq(10L),anyLong())).thenReturn(carton());
when(mapper.pallet(10L,20L,100L)).thenReturn(pallet()); when(mapper.cartons(20L)).thenReturn(java.util.List.of());
service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-0001","op-1"),7L,"张三");
service.countCarton(10L,countRequest("DP-260911-0001"),7L,"张三");
verify(mapper).insertCarton(anyLong(),eq(10L),eq(20L),eq(1L),eq(30L),eq(40L),eq(50L),eq("SKU-1"),any(),eq("BOX-1"),eq(12),eq(12),eq("MATCH"),isNull(),eq("op-1"),eq(7L),eq("张三"));
verify(mapper).insertCartonSku(anyLong(),anyLong(),eq(40L),eq("SKU-1"),any(),eq(12),eq(12),eq("MATCH"),isNull(),eq(1));
}
@Test void unknownPalletCodeCannotCreateAnAdHocPallet(){
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(2); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var request=new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,"DP-260911-9999","op-1");
var request=countRequest("DP-260911-9999");
assertThatThrownBy(()->service.countCarton(10L,request,7L,"张三")).isInstanceOf(BizException.class).hasMessageContaining("测量审核后生成");
verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
}
@@ -61,12 +65,15 @@ class DropshipPalletizationServiceTest {
when(mapper.task(10L,100L)).thenReturn(task()); when(mapper.palletCount(10L)).thenReturn(0); when(mapper.label(10L,"BOX-1")).thenReturn(label());
var result=service.resolveCarton(10L,100L,"BOX-1",7L);
assertThat(result.canCount()).isFalse(); assertThat(result.blockReason()).contains("预计 2 托").contains("当前仅生成 0 个托盘码");
assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",12,DiscrepancyAction.MATCH,null,null,"op-1"),7L,"张三"))
assertThatThrownBy(()->service.countCarton(10L,countRequest(null),7L,"张三"))
.isInstanceOf(BizException.class).hasMessageContaining("预计 2 托");
}
private static TaskRow task(){TaskRow t=new TaskRow();t.setId(10L);t.setTenantId(1L);t.setInboundId(30L);t.setWarehouseId(100L);t.setInboundCode("IN-1");t.setTaskStatus("CLAIMED");t.setAssigneeId(7L);t.setProcessStatus("WAIT_PALLETIZE");t.setTotalCartons(2);t.setCountedCartons(0);t.setAllocatedCartons(0);t.setDiscrepancyCartons(0);t.setExpectedPalletCount(2);t.setVersion(1);return t;}
private static LabelRow label(){LabelRow l=new LabelRow();l.setCartonLabelId(50L);l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setUnitsPerCarton(12);l.setInboundCode("IN-1");l.setBoxCode("BOX-1");return l;}
private static LabelSkuRow labelSku(){LabelSkuRow l=new LabelSkuRow();l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setExpectedQuantity(12);l.setLineNo(1);return l;}
private static CartonSkuRow cartonSku(){CartonSkuRow l=new CartonSkuRow();l.setInboundDetailId(40L);l.setSkuCode("SKU-1");l.setCargoName("货物");l.setExpectedQuantity(12);l.setActualQuantity(12);l.setDiscrepancyStatus("MATCH");l.setLineNo(1);return l;}
private static CountCartonRequest countRequest(String palletNo){return new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),palletNo,"op-1");}
private static PalletRow pallet(){PalletRow p=new PalletRow();p.setId(20L);p.setTaskId(10L);p.setTenantId(1L);p.setInboundId(30L);p.setWarehouseId(100L);p.setPalletNo("DP-260911-0001");p.setPalletStatus("OPEN");p.setVersion(1);return p;}
private static CartonRow carton(){CartonRow c=new CartonRow();c.setId(60L);c.setTaskId(10L);c.setPalletId(20L);c.setInboundId(30L);c.setInboundDetailId(40L);c.setPalletNo("DP-260911-0001");c.setSkuCode("SKU-1");c.setCargoName("货物");c.setBoxCode("BOX-1");c.setExpectedSkuQuantity(12);c.setActualSkuQuantity(12);c.setDiscrepancyStatus("MATCH");return c;}
}
@@ -42,13 +42,31 @@ class InboundCartonLabelPrintServiceTest {
Pallet open = dropshipPallet("OPEN");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(),
when(print.generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.eq("request-open"), org.mockito.ArgumentMatchers.any()))
.thenReturn(new ResponseEntity<>(new byte[] {7, 8, 9}, headers,
org.springframework.http.HttpStatus.OK));
ResponseEntity<byte[]> response = service.generateDropshipPallet(open, "PCHK2608040174", "request-open");
assertThat(response.getBody()).containsExactly(7, 8, 9);
ArgumentCaptor<PrintPdfClient.DropshipPalletA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.DropshipPalletA4PdfRequest.class);
verify(print).generateDropshipPalletA4Documents(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.eq("request-open"), request.capture());
assertThat(request.getValue().templateCode()).isEqualTo("DROPSHIP_PALLET_A4_V1");
assertThat(request.getValue().pageSpec().widthMm()).isEqualByComparingTo("210");
assertThat(request.getValue().pageSpec().heightMm()).isEqualByComparingTo("297");
assertThat(request.getValue().documents()).singleElement().satisfies(document -> {
assertThat(document.palletNo()).isEqualTo("DP-260911-0001");
assertThat(document.inboundCodes()).isEqualTo("PCHK2608040174");
assertThat(document.skuRows()).singleElement().satisfies(row -> {
assertThat(row.skuCode()).isEqualTo("SKU-1");
assertThat(row.cargoName()).isEqualTo("音箱");
assertThat(row.cartonCount()).isEqualTo(1);
assertThat(row.skuQuantity()).isEqualTo(19);
});
});
}
@Test
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.client.PrintableOrderInboundClient;
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
import com.baiyue.firstleg.mapper.InboundCartonLabelSkuMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +33,7 @@ class InboundCartonLabelServiceTest {
@Mock private OrderCargoClient orderCargoClient;
@Mock private PrintableOrderInboundClient printableOrderInboundClient;
@Mock private InboundOrderCreationService inboundOrderCreationService;
@Mock private InboundCartonLabelSkuMapper labelSkus;
private InboundCartonLabelService service;
@BeforeEach
@@ -41,6 +43,7 @@ class InboundCartonLabelServiceTest {
ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient);
ReflectionTestUtils.setField(service, "printableOrderInboundClient", printableOrderInboundClient);
ReflectionTestUtils.setField(service, "inboundOrderCreationService", inboundOrderCreationService);
ReflectionTestUtils.setField(service, "labelSkus", labelSkus);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@@ -75,6 +78,26 @@ class InboundCartonLabelServiceTest {
verify(repository).updatePackageCount(10001L, 70001L, 5);
}
@Test
void mixedCartonGroupCreatesOnePhysicalBoxSeriesWithEverySkuLine() {
InboundOrderEntity inbound = inbound();
inbound.setFulfillmentType("ONE_CLICK_DROPSHIP");
when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound);
when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>());
var skuA = mixedCargo(80001L, 1, "SKU-A", 2, 3, "A");
var skuB = mixedCargo(80002L, 2, "SKU-B", 5, 3, "A");
when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L))
.thenReturn(ApiResponse.success(List.of(skuA, skuB)));
var batch = service.getOrCreate(10001L, 70001L);
assertThat(batch.totalCount()).isEqualTo(3);
verify(labelSkus, org.mockito.Mockito.times(6)).insert(any(Long.class),
org.mockito.ArgumentMatchers.eq(10001L), org.mockito.ArgumentMatchers.eq(70001L), any(Long.class),
any(Long.class), any(String.class), any(), any(Integer.class), any(Integer.class));
}
@Test
void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() {
InboundOrderEntity inbound = inbound();
@@ -283,6 +306,12 @@ class InboundCartonLabelServiceTest {
new BigDecimal(length), new BigDecimal(width), new BigDecimal(height));
}
private OrderCargoClient.CargoLabelBinding mixedCargo(long id, int itemNo, String sku,
int units, int count, String group) {
return new OrderCargoClient.CargoLabelBinding(id, 60001L, itemNo, "货物" + itemNo, sku, count,
units, new BigDecimal("60"), new BigDecimal("40"), new BigDecimal("30"), null, group);
}
private List<InboundCartonLabelEntity> labels(InboundOrderEntity inbound, int total) {
List<InboundCartonLabelEntity> values = new ArrayList<>();
for (int index = 1; index <= total; index++) {
@@ -10,6 +10,7 @@ import com.baiyue.order.entity.OrderCargoEntity;
import com.baiyue.order.repository.OrderRepository;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.PrintableInboundSnapshotService;
import com.baiyue.order.service.OrderCargoPackageCounts;
import com.baiyue.order.client.FirstlegInboundClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
@@ -84,7 +85,7 @@ public class InternalOrderCargoController {
.map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(),
cargo.getCargoName(), cargo.getSkuNo(), cargo.getPackageCount(),
unitsPerCarton(cargo, oneClickDropship), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm(), cargo.getShipmentId()))
cargo.getHeightCm(), cargo.getShipmentId(), cargo.getPackingGroupNo()))
.toList());
}
@@ -109,9 +110,8 @@ public class InternalOrderCargoController {
if (order == null) {
throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户");
}
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 ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount));
}
@@ -187,18 +187,18 @@ public class InternalOrderCargoController {
String skuNo, Integer packageCount, Integer unitsPerCarton,
java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm,
String shipmentId) {
String shipmentId, String packingGroupNo) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, null,
lengthCm, widthCm, heightCm, null);
lengthCm, widthCm, heightCm, null, null);
}
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, skuNo, packageCount, null,
lengthCm, widthCm, heightCm, null);
lengthCm, widthCm, heightCm, null, null);
}
}
@@ -211,7 +211,9 @@ public final class OrderRequests {
@Size(max = 64) String upcNo,
@Size(max = 128) String cargoNameEn,
@Size(max = 20) String customsHsCode,
@Schema(description = "Units per package / 每箱数量;提交时必填", minimum = "1")
@Min(1) Integer unitsPerPackage,
@Schema(description = "Total quantity / 总数量;提交时必填且必须等于每箱数量 × 箱数", minimum = "1")
@Min(1) Integer totalQuantity,
@DecimalMin("0.001") BigDecimal netWeightKg,
@DecimalMin("0.00") BigDecimal unitDeclaredValue,
@@ -226,8 +228,25 @@ public final class OrderRequests {
@Size(max = 64) String referenceId,
@Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId,
@Schema(description = "Authoritative wood-product decision frozen with this cargo line / 木制品权威判定,随货物行冻结", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull Boolean woodenProduct
@NotNull Boolean woodenProduct,
@Schema(description = "Mixed-carton group. Cargo lines with the same value share physical cartons / 混箱组合号;相同组合号的货物行属于同一批物理箱", maxLength = 32)
@Size(max = 32) String packingGroupNo
) {
public Cargo(String cargoName, String hsCode, String packageType, int packageCount,
BigDecimal grossWeightKg, BigDecimal volumeCbm, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, BigDecimal declaredValue, String currency,
boolean dangerousGoods, String cargoRemark, String skuNo, String upcNo,
String cargoNameEn, String customsHsCode, Integer unitsPerPackage, Integer totalQuantity,
BigDecimal netWeightKg, BigDecimal unitDeclaredValue, String material, String usage,
String model, String brand, BigDecimal productLengthCm, BigDecimal productWidthCm,
BigDecimal productHeightCm, String shipmentId, String referenceId,
Long productImageFileId, Boolean woodenProduct) {
this(cargoName, hsCode, packageType, packageCount, grossWeightKg, volumeCbm, lengthCm, widthCm,
heightCm, declaredValue, currency, dangerousGoods, cargoRemark, skuNo, upcNo, cargoNameEn,
customsHsCode, unitsPerPackage, totalQuantity, netWeightKg, unitDeclaredValue, material, usage,
model, brand, productLengthCm, productWidthCm, productHeightCm, shipmentId, referenceId,
productImageFileId, woodenProduct, null);
}
public Cargo(String cargoName, String hsCode, String packageType, int packageCount,
BigDecimal grossWeightKg, BigDecimal volumeCbm, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, BigDecimal declaredValue, String currency,
@@ -240,7 +259,7 @@ public final class OrderRequests {
heightCm, declaredValue, currency, dangerousGoods, cargoRemark, skuNo, upcNo, cargoNameEn,
customsHsCode, unitsPerPackage, totalQuantity, netWeightKg, unitDeclaredValue, material, usage,
model, brand, productLengthCm, productWidthCm, productHeightCm, shipmentId, referenceId,
productImageFileId, false);
productImageFileId, false, null);
}
}
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
* 申报价值及危险品标识等货物信息。</p>
*/
@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;
}
@@ -84,9 +84,8 @@ public class MerchantInboundOrderListService {
List<OrderEntity> missing, Map<Long, List<OrderCargoEntity>> cargoes) {
try {
List<FirstlegInboundClient.OrderApprovedInboundCommand> 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);
@@ -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<OrderRequests.Cargo> 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<OrderPartyEntity> 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<String, OrderRequests.Cargo> 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<OrderRequests.Cargo> cargoes, String fulfillmentType) {
Set<String> 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<OrderCargoEntity> cargoes, String fulfillmentType) {
Set<String> 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;
}
@@ -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<OrderCargoEntity> cargoes, String fulfillmentType) {
Set<String> 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();
}
}
@@ -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) {
@@ -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);
}
@@ -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<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
@@ -492,7 +492,7 @@ public class UnifiedOrderQueryService {
private record CargoSummary(List<String> skuNos, List<String> shipmentIds, List<String> referenceIds,
Integer totalQuantity, Integer totalPackageCount, BigDecimal grossWeightKg,
BigDecimal netWeightKg, BigDecimal volumeCbm) {
private static CargoSummary from(List<OrderCargoEntity> cargoes) {
private static CargoSummary from(List<OrderCargoEntity> cargoes, String fulfillmentType) {
List<String> skuNos = cargoes.stream().map(OrderCargoEntity::getSkuNo)
.filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).distinct().toList();
List<String> 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<Integer> 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);
+1
View File
@@ -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',
@@ -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
@@ -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:
@@ -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'");
}
}
@@ -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;
}
@@ -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;
}
}
+5 -5
View File
@@ -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`;渲染变更同时核对页面尺寸、页数、页序、二维码或一维码反向解码和字体嵌入。
+2 -1
View File
@@ -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`。
@@ -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<byte[]> 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);
}
}
@@ -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<Document> 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<SkuRow> skuRows) {}
public record SkuRow(String inboundCode, String skuCode, String cargoName,
int cartonCount, int skuQuantity) {}
}
@@ -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<DropshipPalletA4PdfDtos.SkuRow> 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<String> 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<String> wrap(PDFont font, String raw, float size, float maxWidth,
int maxLines) throws IOException {
String remaining = raw == null || raw.isBlank() ? "—" : raw;
List<String> 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();
}
}
@@ -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();
}
}
@@ -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<Character> 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<DropshipPalletA4PdfDtos.Document> 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<DropshipPalletA4PdfDtos.SkuRow> 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) {}
}
@@ -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]
@@ -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
}]
}]
}
""";
}
}
@@ -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);
}
}
@@ -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<String> inboundCodes = new ArrayList<>();
for (int index = 1; index <= 10; index++) inboundCodes.add("NYEXX260911" + String.format("%04d", 240 + index));
List<DropshipPalletA4PdfDtos.SkuRow> 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<DropshipPalletA4PdfDtos.SkuRow> 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");
}
}