feat: support mixed-SKU dropship cartons
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
+49
-20
@@ -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){}
|
||||
}
|
||||
|
||||
+18
-11
@@ -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
|
||||
|
||||
+12
-5
@@ -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;}
|
||||
}
|
||||
|
||||
+19
-1
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+93
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user