From 8b0c99593e8a6a693388ac1b8192589a79c4370c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 1 Sep 2026 13:41:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E4=BB=B6=E4=BB=A3=E5=8F=91=E6=8B=A3?= =?UTF-8?q?=E8=B4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 79 +++++++ firstleg/AGENTS.md | 3 +- .../firstleg/client/PrintPdfClient.java | 2 +- ...atformDropshipPalletizationController.java | 117 ++++++++++ .../firstleg/domain/InboundProcessStatus.java | 4 +- .../dto/DropshipPalletizationDtos.java | 51 +++++ .../firstleg/dto/InboundCartonLabels.java | 11 +- .../firstleg/dto/InboundOrderDetail.java | 7 + .../firstleg/dto/UnifiedInboundQuery.java | 2 +- .../mapper/DropshipPalletizationMapper.java | 203 ++++++++++++++++++ .../firstleg/mapper/InboundOrderMapper.java | 13 +- .../mapper/LoadingCompletedEventMapper.java | 26 ++- .../repository/InboundOrderRepository.java | 2 +- .../service/DropshipPalletizationService.java | 197 +++++++++++++++++ .../FirstlegDocumentNumberGenerator.java | 2 +- .../InboundCartonLabelPrintService.java | 25 ++- .../service/InboundCartonLabelService.java | 2 +- .../service/InboundOrderQueryService.java | 8 +- .../service/MeasurementReviewService.java | 15 +- .../service/UnifiedInboundQueryService.java | 5 +- .../resources/db/phase2-inbound-schema.sql | 80 ++++++- .../openapi/dropship-palletization-v1.yaml | 54 +++++ .../main/resources/openapi/firstleg-v1.yaml | 2 +- .../service/InboundOrderQueryServiceTest.java | 2 + .../service/MeasurementReviewServiceTest.java | 1 + .../baiyue/print/dto/InboundMarkPdfDtos.java | 7 +- .../print/renderer/InboundMarkV1Renderer.java | 3 + .../print/service/InboundMarkPdfService.java | 11 +- .../resources/openapi/print-internal-v1.yaml | 5 + 29 files changed, 910 insertions(+), 29 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java create mode 100644 firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a8af86c29..aa48448bb 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16702,3 +16702,82 @@ CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; + + +-- 一件代发测量完成后的独立 PDA 合托;不代表国内仓库存入库。 +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task ( + id BIGINT UNSIGNED NOT NULL COMMENT '合托任务ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', + task_status VARCHAR(24) NOT NULL COMMENT 'PENDING/CLAIMED/COMPLETED', + total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', + allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', + assignee_id BIGINT UNSIGNED NULL, + assignee_name VARCHAR(64) NULL, + claimed_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_task_inbound (tenant_id,inbound_id), + KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), + CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), + CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), + CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) +) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; + +CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '合托托盘ID', + task_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + warehouse_id BIGINT UNSIGNED NOT NULL, + pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', + pallet_status VARCHAR(16) NOT NULL COMMENT 'OPEN/COMPLETED', + open_operation_id VARCHAR(128) NOT NULL, + seal_operation_id VARCHAR(128) NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_pallet_no (pallet_no), + UNIQUE KEY uk_dropship_pallet_open_op (open_operation_id), + UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), + KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), + CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), + CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) +) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; + +CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( + id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + pallet_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + inbound_detail_id BIGINT UNSIGNED NOT NULL, + carton_label_id BIGINT UNSIGNED NOT NULL, + sku_code VARCHAR(128) NOT NULL, + cargo_name VARCHAR(255) NULL, + box_code VARCHAR(64) NOT NULL, + operation_id VARCHAR(128) NOT NULL, + bound_by BIGINT UNSIGNED NOT NULL, + bound_by_name VARCHAR(64) NULL, + bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), + KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), + KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), + KEY idx_dropship_carton_operation (operation_id), + CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), + 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和入仓单明细'; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 5aaa6edd5..965de23b9 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -15,7 +15,8 @@ | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 | -| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `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 时禁止生成箱唛。`box_code`、二维码内容和现有打印版式不携带 SKU,不能因该绑定改变。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `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` 托盘号,全部箱数完成后进入 `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` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java index 832b154ba..923ba2e5c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java @@ -34,7 +34,7 @@ public interface PrintPdfClient { } record Label(String topCode, String warehouseText, String cartonText, - String routeText, String qrContent) { + String routeText, String qrContent, String skuCode) { } record FreightA4PdfRequest(String templateCode, PageSpec pageSpec, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java new file mode 100644 index 000000000..337dc2fb0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -0,0 +1,117 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Task; +import com.baiyue.firstleg.service.DropshipPalletizationService; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.InboundCartonLabelPrintService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.List; + +/** 国内 PDA 一件代发合托;与装柜 PDA 分离。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/first-leg/pda-dropship") +public class PlatformDropshipPalletizationController { + @Resource private DropshipPalletizationService service; + @Resource private InboundCartonLabelPrintService printService; + + @GetMapping("/tasks/pending") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse> pending( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) @Size(max = 64) String keyword) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.pending(user.userId(), keyword)); + } + + @GetMapping("/tasks/{taskId}") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.task(taskId, warehouseId, user.userId())); + } + + @PostMapping("/tasks/{taskId}/claim") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse claim(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody DropshipPalletizationDtos.ClaimRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.claim(taskId, request.warehouseId(), user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/pallets/open") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse open(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody DropshipPalletizationDtos.OpenPalletRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.open(taskId, request, user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/pallets/{palletId}/scan") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse scan( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, @PathVariable @Positive long palletId, + @Valid @RequestBody DropshipPalletizationDtos.ScanRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.scan(taskId, palletId, request, user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/pallets/{palletId}/seal") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse seal(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long palletId, + @Valid @RequestBody DropshipPalletizationDtos.SealPalletRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.seal(taskId, palletId, request, user.userId(), user.username())); + } + + @GetMapping("/inbound-orders/{inboundId}/pallets") + @PreAuthorize("hasAuthority('firstleg:inbound:list')") + public ApiResponse> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long inboundId, + @RequestParam @Positive long accountId) { + FirstlegScopeGuard.platform(user, accountId); + return ApiResponse.success(service.inboundPallets(accountId, inboundId)); + } + + @GetMapping("/inbound-orders/{inboundId}/pallets/{palletId}/print") + @PreAuthorize("hasAuthority('firstleg:inbound:list')") + public ResponseEntity printPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long inboundId, + @PathVariable @Positive long palletId, + @RequestParam @Positive long accountId, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + FirstlegScopeGuard.platform(user, accountId); + Pallet pallet = service.inboundPallets(accountId, inboundId).stream() + .filter(item -> item.palletId() == palletId).findFirst() + .orElseThrow(() -> new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在")); + return printService.generateDropshipPallet(pallet, requestId); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index 469b4a156..2682ec68c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -20,6 +20,8 @@ public final class InboundProcessStatus { public static final String FEE_CONFIRMING = "FEE_CONFIRMING"; /** 费用已确认,正在执行托盘、库位占用和入库收口。 */ public static final String INBOUNDING = "INBOUNDING"; + /** 一件代发已完成测量但未真实入库,等待 PDA 合托。 */ + public static final String WAIT_PALLETIZE = "WAIT_PALLETIZE"; /** 入库和费用发布完成,等待分柜。 */ public static final String WAIT_SPLIT = "WAIT_SPLIT"; /** 已进入装柜作业,PDA 可执行拣货。 */ @@ -33,7 +35,7 @@ public final class InboundProcessStatus { public static final Set VALUES = Set.of( PREPARING, WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, - WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_SPLIT, + WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, VOIDED ); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java new file mode 100644 index 000000000..f03398ba2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java @@ -0,0 +1,51 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +/** 一件代发测量完成后的独立 PDA 合托契约。 */ +public final class DropshipPalletizationDtos { + private DropshipPalletizationDtos() {} + + public record ClaimRequest(@Positive long warehouseId) {} + + public record OpenPalletRequest(@Positive long warehouseId, + @NotBlank @Size(max = 128) String operationId) {} + + public record ScanRequest(@Positive long warehouseId, + @NotBlank @Size(max = 64) String boxCode, + @Positive @Max(2000) int cartonCount, + @NotBlank @Size(max = 128) String operationId) {} + + public record SealPalletRequest(@Positive long warehouseId, + @NotBlank @Size(max = 128) String operationId, + @Positive int expectedVersion) {} + + public record PendingTask(Long taskId, Long inboundId, String inboundCode, Long tenantId, + String customerName, Long warehouseId, String warehouseName, + Integer totalCartons, Integer allocatedCartons, String taskStatus, + LocalDateTime createdAt) {} + + public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId, + String customerName, Long warehouseId, String warehouseName, + Integer totalCartons, Integer allocatedCartons, String taskStatus, + Long assigneeId, String assigneeName, Integer version, + List pallets) {} + + public record Pallet(Long palletId, String palletNo, String palletStatus, + Integer cartonCount, Integer skuCount, Integer version, + List items, LocalDateTime completedAt) {} + + public record SkuItem(Long inboundId, String inboundCode, Long inboundDetailId, + String skuCode, String cargoName, Integer cartonCount, + List cartonMarks) {} + + public record ScanResult(String skuCode, String cargoName, String inboundCode, + int addedCartons, int allocatedCartons, int remainingCartons, + Pallet pallet) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java index 8a0d67afb..937a23367 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java @@ -15,8 +15,17 @@ public final class InboundCartonLabels { public record Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName, Integer detailPackageCount, String boxCode, Integer totalCount, Integer cartonIndex, String boxSpecification, BigDecimal lengthCm, - BigDecimal widthCm, BigDecimal heightCm, String topCode, + BigDecimal widthCm, BigDecimal heightCm, String skuCode, String topCode, String warehouseText, String cartonText, String routeText) { + public Label(Long id, Long inboundDetailId, Integer cargoItemNo, String cargoName, + Integer detailPackageCount, String boxCode, Integer totalCount, + Integer cartonIndex, String boxSpecification, BigDecimal lengthCm, + BigDecimal widthCm, BigDecimal heightCm, String topCode, + String warehouseText, String cartonText, String routeText) { + this(id, inboundDetailId, cargoItemNo, cargoName, detailPackageCount, boxCode, + totalCount, cartonIndex, boxSpecification, lengthCm, widthCm, heightCm, + null, topCode, warehouseText, cartonText, routeText); + } } public record Resolution(Long inboundId, Long orderId, Long inboundDetailId, String boxCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java index c511b95e0..8ba7ec3b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderDetail.java @@ -8,11 +8,18 @@ import java.util.List; public record InboundOrderDetail( InboundOrderSummary summary, String publicRemark, + List dropshipPallets, List palletLocations, List files, List internalNotes, List statusHistory ) { + public InboundOrderDetail(InboundOrderSummary summary, String publicRemark, + List palletLocations, List files, + List internalNotes, + List statusHistory) { + this(summary, publicRemark, List.of(), palletLocations, files, internalNotes, statusHistory); + } public record PalletLocationSummary( Integer allocationNo, Long warehouseId, Long palletId, String palletCode, BigDecimal palletCapacityCbm, BigDecimal allocatedVolumeCbm, Integer allocatedPackageCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index f7d79abdb..dfa3fdff9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -14,7 +14,7 @@ public record UnifiedInboundQuery( Long tenantId, @Size(max = 64) String keyword, @Size(max = 64) String orderNo, - @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", + @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"}) @Size(max = 32) String mainStatus, Long customerId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java new file mode 100644 index 000000000..86d5266e7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -0,0 +1,203 @@ +package com.baiyue.firstleg.mapper; + +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask; +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 org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +/** 一件代发合托持久化;任务、托盘和逐箱绑定均独立于装柜拣货。 */ +@Mapper +public interface DropshipPalletizationMapper { + @Insert(""" + INSERT IGNORE INTO fl_dropship_pallet_task + (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,allocated_cartons,version,created_at,updated_at) + VALUES(#{id},#{tenantId},#{inboundId},#{warehouseId},'PENDING',#{totalCartons},0,1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertTask(@Param("id") long id, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("warehouseId") long warehouseId, + @Param("totalCartons") int totalCartons); + + @Select(""" + SELECT t.id task_id,t.inbound_id,o.inbound_code,t.tenant_id,o.customer_name,t.warehouse_id, + o.warehouse_name,t.total_cartons,t.allocated_cartons,t.task_status,t.created_at + FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id + WHERE t.task_status IN ('PENDING','CLAIMED') + AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId}) + AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') + OR o.customer_name LIKE CONCAT('%',#{keyword},'%')) + ORDER BY t.created_at,t.id + """) + List pending(@Param("operatorId") long operatorId, @Param("keyword") String keyword); + + @Select(""" + SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type + FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id + WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId} + """) + TaskRow task(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId); + + @Update(""" + UPDATE fl_dropship_pallet_task + SET task_status='CLAIMED',assignee_id=#{operatorId},assignee_name=#{operatorName}, + claimed_at=COALESCE(claimed_at,CURRENT_TIMESTAMP(3)),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{taskId} AND warehouse_id=#{warehouseId} AND task_status IN ('PENDING','CLAIMED') + AND (assignee_id IS NULL OR assignee_id=#{operatorId}) + """) + int claim(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Insert(""" + INSERT INTO fl_dropship_pallet + (id,task_id,tenant_id,inbound_id,warehouse_id,pallet_status,open_operation_id,version,created_by,created_by_name,created_at,updated_at) + VALUES(#{id},#{taskId},#{tenantId},#{inboundId},#{warehouseId},'OPEN',#{operationId},1, + #{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3)) + """) + int insertPallet(@Param("id") long id, @Param("taskId") long taskId, + @Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("warehouseId") long warehouseId, @Param("operationId") String operationId, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}") + PalletRow palletByOpenOperation(@Param("operationId") String operationId); + + @Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") + PalletRow palletBySealOperation(@Param("operationId") String operationId); + + @Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}") + PalletRow pallet(@Param("taskId") long taskId, @Param("palletId") long palletId, + @Param("warehouseId") long warehouseId); + + @Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") + List pallets(@Param("taskId") long taskId); + + @Select(""" + SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id + WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id + """) + List palletsByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") + String inboundCode(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + + @Select(""" + SELECT l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, + l.inbound_code,l.box_code + 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 l.id carton_label_id,l.inbound_detail_id,l.sku_code,l.cargo_name,l.detail_package_count, + l.inbound_code,l.box_code + FROM fl_inbound_carton_label l + WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} + AND l.inbound_detail_id=#{detailId} AND l.batch_status='ACTIVE' + AND NOT EXISTS (SELECT 1 FROM fl_dropship_pallet_carton c + WHERE c.task_id=#{taskId} AND c.carton_label_id=l.id) + ORDER BY CASE WHEN l.box_code=#{preferredBoxCode} THEN 0 ELSE 1 END,l.carton_index,l.id + LIMIT #{count} + """) + List unboundLabels(@Param("taskId") long taskId, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("detailId") long detailId, + @Param("preferredBoxCode") String preferredBoxCode, + @Param("count") int count); + + @Select("SELECT COUNT(*) FROM fl_dropship_pallet_carton WHERE operation_id=#{operationId}") + int operationCount(@Param("operationId") String operationId); + + @Insert(""" + INSERT INTO fl_dropship_pallet_carton + (id,task_id,pallet_id,tenant_id,inbound_id,inbound_detail_id,carton_label_id,sku_code,cargo_name, + box_code,operation_id,bound_by,bound_by_name,bound_at) + VALUES(#{id},#{taskId},#{palletId},#{tenantId},#{inboundId},#{detailId},#{labelId},#{skuCode},#{cargoName}, + #{boxCode},#{operationId},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3)) + """) + int insertCarton(@Param("id") long id, @Param("taskId") long taskId, + @Param("palletId") long palletId, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("detailId") long detailId, + @Param("labelId") long labelId, @Param("skuCode") String skuCode, + @Param("cargoName") String cargoName, @Param("boxCode") String boxCode, + @Param("operationId") String operationId, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_dropship_pallet_task + SET allocated_cartons=allocated_cartons+#{count},version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{taskId} AND task_status='CLAIMED' AND assignee_id=#{operatorId} + AND allocated_cartons+#{count}<=total_cartons + """) + int addAllocated(@Param("taskId") long taskId, @Param("operatorId") long operatorId, + @Param("count") int count); + + @Update(""" + UPDATE fl_dropship_pallet + SET pallet_no=#{palletNo},pallet_status='COMPLETED',seal_operation_id=#{operationId}, + completed_by=#{operatorId},completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3), + version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion} + AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId}) + """) + int seal(@Param("taskId") long taskId, @Param("palletId") long palletId, + @Param("expectedVersion") int expectedVersion, @Param("palletNo") String palletNo, + @Param("operationId") String operationId, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") + int openPalletCount(@Param("taskId") long taskId); + + @Update(""" + UPDATE fl_dropship_pallet_task + SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{taskId} AND task_status='CLAIMED' AND allocated_cartons=total_cartons + AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN') + """) + int completeTask(@Param("taskId") long taskId); + + @Update(""" + UPDATE fl_inbound_order o + SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p + WHERE p.inbound_id=o.id AND p.pallet_status='COMPLETED'),o.updated_by=#{operatorId},o.version=o.version+1 + WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE' + AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED') + """) + int completeInbound(@Param("taskId") long taskId, @Param("tenantId") long tenantId, + @Param("inboundId") long inboundId, @Param("operatorId") long operatorId); + + @Select(""" + SELECT c.* FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} + ORDER BY c.sku_code,c.inbound_detail_id,c.id + """) + List cartons(@Param("palletId") long palletId); + + @Getter @Setter class TaskRow { + private Long id; private Long tenantId; private Long inboundId; private Long warehouseId; + private String inboundCode; private String customerName; private String warehouseName; + private String taskStatus; private Integer totalCartons; private Integer allocatedCartons; + private Long assigneeId; private String assigneeName; private Integer version; + private String processStatus; private String fulfillmentType; + } + @Getter @Setter class PalletRow { + private Long id; private Long taskId; private Long tenantId; private Long inboundId; + private Long warehouseId; private String palletNo; private String palletStatus; + private String openOperationId; private String sealOperationId; private Integer version; + private LocalDateTime completedAt; + } + @Getter @Setter class LabelRow { + private Long cartonLabelId; private Long inboundDetailId; private String skuCode; + private String cargoName; private Integer detailPackageCount; private String inboundCode; + private String boxCode; + } + @Getter @Setter class CartonRow { + private Long inboundId; private Long inboundDetailId; private String skuCode; + private String cargoName; private String boxCode; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index 7ec6a1a22..4e532cafe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -26,7 +26,9 @@ public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED', + SET process_status = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP' + THEN 'WAIT_PALLETIZE' ELSE 'WAIT_SPLIT' END, + measure_review_status = 'APPROVED', actual_gross_weight_kg = (SELECT SUM(weight.gross_weight_kg) FROM fl_inbound_weight_result weight WHERE weight.tenant_id = #{tenantId} AND weight.inbound_id = #{inboundId} @@ -41,7 +43,9 @@ public interface InboundOrderMapper extends BaseMapper { volume_weight_kg = (SELECT COALESCE(SUM(item.volume_weight_kg), 0) FROM fl_inbound_measurement_item item WHERE item.tenant_id = #{tenantId} AND item.measurement_id = #{measurementId}), - inbounded_at = CURRENT_TIMESTAMP(3), updated_by = #{operatorId}, version = version + 1 + inbounded_at = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP' + THEN NULL ELSE CURRENT_TIMESTAMP(3) END, + updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 AND process_status = 'MEASURE_PENDING' AND measure_review_status = 'PENDING' AND version = #{expectedVersion} @@ -65,7 +69,10 @@ public interface InboundOrderMapper extends BaseMapper { @Update(""" UPDATE fl_inbound_order - SET process_status = 'WAIT_SPLIT', inbounded_at = CURRENT_TIMESTAMP(3), + SET process_status = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP' + THEN 'WAIT_PALLETIZE' ELSE 'WAIT_SPLIT' END, + inbounded_at = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP' + THEN NULL ELSE CURRENT_TIMESTAMP(3) END, updated_by = #{operatorId}, version = version + 1 WHERE tenant_id = #{tenantId} AND id = #{inboundId} AND deleted = 0 AND process_status = 'MEASURE_REJECTED' AND measure_review_status = 'REJECTED' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index b263c306d..fb737b58d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -80,7 +80,7 @@ public interface LoadingCompletedEventMapper { """) List palletRows(@Param("planId") long planId); - /** 一件代发现场逐箱扫描结果;同一实物托盘可以包含多个客户,但行仍保留客户与入仓单归属。 */ + /** 一件代发测量后合托的逐箱结果;装柜只读取已冻结事实,不再现场重新打托。 */ @Select(""" SELECT carton.tenant_id AS account_id,carton.inbound_id AS source_inbound_id, pallet.id AS pallet_id,1 AS actual_binding, @@ -88,20 +88,28 @@ public interface LoadingCompletedEventMapper { COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count, carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code, item.product_name_zh,item.package_count,item.product_quantity,carton.box_code - FROM fl_loading_work_pallet pallet - JOIN fl_loading_work_pallet_carton carton ON carton.work_pallet_id=pallet.id - JOIN fl_loading_plan_item item ON item.id=carton.loading_plan_item_id AND item.deleted=0 - JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id - AND task.loading_plan_id=pallet.loading_plan_id - WHERE pallet.loading_plan_id=#{planId} AND pallet.pallet_status='SEALED' + FROM fl_dropship_pallet pallet + JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id + JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId} + AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id + AND item.inbound_detail_id=carton.inbound_detail_id AND item.deleted=0 + WHERE pallet.pallet_status='COMPLETED' ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id """) List actualPalletRows(@Param("planId") long planId); - @Select("SELECT COUNT(*) FROM fl_loading_work_pallet WHERE loading_plan_id=#{planId} AND pallet_status<>'CANCELLED'") + @Select(""" + SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p + JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id + AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='COMPLETED' + """) int workPalletCount(@Param("planId") long planId); - @Select("SELECT COUNT(*) FROM fl_loading_work_pallet WHERE loading_plan_id=#{planId} AND pallet_status='OPEN'") + @Select(""" + SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p + JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id + AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN' + """) int openWorkPalletCount(@Param("planId") long planId); @Getter @Setter diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index e11916d90..e6a62ed9d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -244,7 +244,7 @@ public class InboundOrderRepository { .filter(StringUtils::hasText) .collect(Collectors.joining("、")); return new InboundOrderDetail(InboundOrderSummary.from(order, transport, pickup, locationCodes), - order.getPublicRemark(), + order.getPublicRemark(), List.of(), palletSummaries, fileSummaries, noteSummaries, historySummaries); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java new file mode 100644 index 000000000..08253dbb3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -0,0 +1,197 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.ScanResult; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.SkuItem; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Task; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.CartonRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.LabelRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.TaskRow; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 测量完成即下发的一件代发合托,不创建国内仓库托盘、库位或库存。 */ +@Service +public class DropshipPalletizationService { + @Resource private DropshipPalletizationMapper mapper; + @Resource private FirstlegDocumentNumberGenerator numbers; + + public void createTask(InboundOrderEntity inbound) { + if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return; + if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { + throw invalid("一件代发测量完成箱数无效,不能下发合托任务"); + } + mapper.insertTask(IdWorker.getId(), inbound.getTenantId(), inbound.getId(), + inbound.getWarehouseId(), inbound.getTotalPackageCount()); + } + + public List pending(long operatorId, String keyword) { + return mapper.pending(operatorId, normalize(keyword)); + } + + @Transactional + public Task claim(long taskId, long warehouseId, long operatorId, String operatorName) { + if (mapper.claim(taskId, warehouseId, operatorId, operatorName) != 1) { + throw conflict("任务已被其他人员领取或状态已变化"); + } + return task(taskId, warehouseId, operatorId); + } + + public Task task(long taskId, long warehouseId, long operatorId) { + TaskRow row = requireTask(taskId, warehouseId); + if (row.getAssigneeId() != null && row.getAssigneeId() != operatorId) { + throw conflict("任务已由其他人员领取"); + } + return view(row); + } + + @Transactional + public Pallet open(long taskId, DropshipPalletizationDtos.OpenPalletRequest request, + long operatorId, String operatorName) { + TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); + String operationId = request.operationId().trim(); + PalletRow replay = mapper.palletByOpenOperation(operationId); + if (replay != null) { + if (replay.getTaskId() != taskId) throw conflict("开托幂等号已用于其他任务"); + return palletView(replay, task.getInboundCode()); + } + long id = IdWorker.getId(); + mapper.insertPallet(id, taskId, task.getTenantId(), task.getInboundId(), task.getWarehouseId(), + operationId, operatorId, operatorName); + return palletView(mapper.pallet(taskId, id, request.warehouseId()), task.getInboundCode()); + } + + @Transactional + public ScanResult scan(long taskId, long palletId, DropshipPalletizationDtos.ScanRequest request, + long operatorId, String operatorName) { + TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); + PalletRow pallet = requireOpenPallet(taskId, palletId, request.warehouseId()); + String operationId = request.operationId().trim(); + LabelRow scanned = mapper.label(taskId, request.boxCode().trim()); + if (scanned == null) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前一件代发入仓单"); + if (!StringUtils.hasText(scanned.getSkuCode())) throw conflict("箱唛缺少SKU,不能合托"); + if (mapper.operationCount(operationId) > 0) { + return scanResult(scanned, request.cartonCount(), task, pallet); + } + List labels = mapper.unboundLabels(taskId, task.getTenantId(), task.getInboundId(), + scanned.getInboundDetailId(), scanned.getBoxCode(), request.cartonCount()); + if (labels.size() != request.cartonCount()) { + throw conflict("本SKU剩余未上托箱数不足,请刷新任务后重试"); + } + for (LabelRow label : labels) { + mapper.insertCarton(IdWorker.getId(), taskId, palletId, task.getTenantId(), task.getInboundId(), + label.getInboundDetailId(), label.getCartonLabelId(), label.getSkuCode(), label.getCargoName(), + label.getBoxCode(), operationId, operatorId, operatorName); + } + if (mapper.addAllocated(taskId, operatorId, labels.size()) != 1) { + throw conflict("任务箱数已变化,请刷新后重试"); + } + TaskRow refreshed = requireTask(taskId, request.warehouseId()); + return scanResult(scanned, labels.size(), refreshed, + mapper.pallet(taskId, palletId, request.warehouseId())); + } + + @Transactional + public Task seal(long taskId, long palletId, DropshipPalletizationDtos.SealPalletRequest request, + long operatorId, String operatorName) { + TaskRow task = requireWorkingTask(taskId, request.warehouseId(), operatorId); + PalletRow replay = mapper.palletBySealOperation(request.operationId().trim()); + if (replay != null) { + if (replay.getTaskId() != taskId || replay.getId() != palletId) { + throw conflict("封托幂等号已用于其他托盘"); + } + return view(requireTask(taskId, request.warehouseId())); + } + requireOpenPallet(taskId, palletId, request.warehouseId()); + String palletNo = numbers.next("DP"); + if (mapper.seal(taskId, palletId, request.expectedVersion(), palletNo, request.operationId().trim(), + operatorId, operatorName) != 1) { + throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试"); + } + TaskRow refreshed = requireTask(taskId, request.warehouseId()); + if (refreshed.getAllocatedCartons().equals(refreshed.getTotalCartons()) + && mapper.openPalletCount(taskId) == 0) { + if (mapper.completeTask(taskId) != 1 + || mapper.completeInbound(taskId, refreshed.getTenantId(), refreshed.getInboundId(), operatorId) != 1) { + throw conflict("合托完成状态已变化,请刷新后重试"); + } + } + return view(requireTask(taskId, request.warehouseId())); + } + + public List inboundPallets(long tenantId, long inboundId) { + String inboundCode = mapper.inboundCode(tenantId, inboundId); + if (inboundCode == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); + return mapper.palletsByInbound(tenantId, inboundId).stream() + .map(row -> palletView(row, inboundCode)).toList(); + } + + private ScanResult scanResult(LabelRow label, int added, TaskRow task, PalletRow pallet) { + return new ScanResult(label.getSkuCode(), label.getCargoName(), label.getInboundCode(), added, + task.getAllocatedCartons(), task.getTotalCartons() - task.getAllocatedCartons(), + palletView(pallet, task.getInboundCode())); + } + + private Task view(TaskRow row) { + return new Task(row.getId(), row.getInboundId(), row.getInboundCode(), row.getTenantId(), + row.getCustomerName(), row.getWarehouseId(), row.getWarehouseName(), row.getTotalCartons(), + row.getAllocatedCartons(), row.getTaskStatus(), row.getAssigneeId(), row.getAssigneeName(), + row.getVersion(), mapper.pallets(row.getId()).stream() + .map(pallet -> palletView(pallet, row.getInboundCode())).toList()); + } + + private Pallet palletView(PalletRow row, String inboundCode) { + List cartons = mapper.cartons(row.getId()); + Map> groups = new LinkedHashMap<>(); + for (CartonRow carton : cartons) { + groups.computeIfAbsent(carton.getInboundDetailId() + "|" + carton.getSkuCode(), ignored -> new ArrayList<>()) + .add(carton); + } + List items = groups.values().stream().map(values -> { + CartonRow first = values.get(0); + return new SkuItem(first.getInboundId(), inboundCode, first.getInboundDetailId(), first.getSkuCode(), + first.getCargoName(), values.size(), values.stream().map(CartonRow::getBoxCode).toList()); + }).toList(); + return new Pallet(row.getId(), row.getPalletNo(), row.getPalletStatus(), cartons.size(), items.size(), + row.getVersion(), items, row.getCompletedAt()); + } + + private TaskRow requireWorkingTask(long taskId, long warehouseId, long operatorId) { + TaskRow task = requireTask(taskId, warehouseId); + if (!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待合托任务"); + if (task.getAssigneeId() == null || task.getAssigneeId() != operatorId) throw conflict("任务不属于当前操作员"); + if (!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待合托状态"); + return task; + } + + private TaskRow requireTask(long taskId, long warehouseId) { + TaskRow task = mapper.task(taskId, warehouseId); + if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的一件代发合托任务"); + return task; + } + + private PalletRow requireOpenPallet(long taskId, long palletId, long warehouseId) { + PalletRow pallet = mapper.pallet(taskId, palletId, warehouseId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "托盘不存在"); + if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘已完成,不能继续上托"); + return pallet; + } + + private static String normalize(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java index d69d567cf..fdec50227 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java @@ -19,7 +19,7 @@ import java.util.Set; public class FirstlegDocumentNumberGenerator { private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "CAB", "LP"); + private static final Set SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "CAB", "LP", "DP"); private static final long MAX_DAILY_SEQUENCE = 9_999L; private final JdbcTemplate jdbc; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java index eaef51764..7db84ee00 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -19,6 +19,7 @@ import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.util.List; import java.util.ArrayList; +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet; /** Allocates stable carton codes and forwards immutable label facts to Print. */ @Service @@ -91,6 +92,28 @@ public class InboundCartonLabelPrintService { return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody()); } + /** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */ + public ResponseEntity generateDropshipPallet(Pallet pallet, String requestId) { + if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "只有已完成且包含货物的一件代发托盘可以打印"); + } + String inboundCodes = pallet.items().stream().map(item -> item.inboundCode()) + .filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/")); + String skuSummary = pallet.items().stream() + .map(item -> item.skuCode() + "×" + item.cartonCount()) + .collect(java.util.stream.Collectors.joining(" / ")); + PrintPdfClient.Label label = new PrintPdfClient.Label(pallet.palletNo(), inboundCodes, + "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)); + try { + return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request)); + } catch (FeignException | NoFallbackAvailableException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); + } + } + private ResponseEntity generatePdf(String fileName, List labels, String requestId) { PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest( @@ -105,7 +128,7 @@ public class InboundCartonLabelPrintService { private PrintPdfClient.Label toPrintLabel(InboundCartonLabels.Label label) { return new PrintPdfClient.Label(label.topCode(), label.warehouseText(), label.cartonText(), - label.routeText(), label.boxCode()); + label.routeText(), label.boxCode(), label.skuCode()); } private ResponseEntity requirePdf(ResponseEntity response) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index a54d57828..b73623379 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -291,7 +291,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { 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(), inbound.getInboundCode(), + entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), entity.getSkuCode(), inbound.getInboundCode(), fallback(inbound.getWarehouseName(), "WAREHOUSE"), "CTNS:" + entity.getTotalCartonCount() + "-" + entity.getCartonIndex(), fallback(inbound.getChannelName(), fallback(inbound.getChannelCode(), "ROUTE")))) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java index 5ed5b2e27..cec47b1ae 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java @@ -19,6 +19,8 @@ import org.springframework.util.StringUtils; public class InboundOrderQueryService { @Resource private InboundOrderRepository repository; + @Resource + private DropshipPalletizationService dropshipPalletization; public PageResult page(Long tenantId, InboundOrderQuery query) { validateQuery(query); @@ -30,7 +32,11 @@ public class InboundOrderQueryService { if (order == null) { throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); } - return repository.detail(tenantId, order); + InboundOrderDetail detail = repository.detail(tenantId, order); + if (!"ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) return detail; + return new InboundOrderDetail(detail.summary(), detail.publicRemark(), + dropshipPalletization.inboundPallets(tenantId, inboundId), detail.palletLocations(), + detail.files(), detail.internalNotes(), detail.statusHistory()); } private void validateQuery(InboundOrderQuery query) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index 6700292b4..66739a44a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -33,6 +33,8 @@ public class MeasurementReviewService { private InboundIdempotencyService idempotency; @Resource private InboundProperties properties; + @Resource + private DropshipPalletizationService dropshipPalletization; @Transactional public void approve(long tenantId, long operatorId, String operatorName, long inboundId, @@ -117,8 +119,13 @@ public class MeasurementReviewService { } repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result, request.reason(), false, null, false, operatorId, operatorName)); + if (approved && "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) { + dropshipPalletization.createTask(order); + } + String approvedStatus = "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType()) + ? "WAIT_PALLETIZE" : "WAIT_SPLIT"; repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING", - approved ? "WAIT_SPLIT" : "MEASURE_REJECTED", + approved ? approvedStatus : "MEASURE_REJECTED", approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } @@ -152,9 +159,13 @@ public class MeasurementReviewService { if (repository.releaseRejected(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) { throw concurrentConflict(); } + if ("ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) { + dropshipPalletization.createTask(order); + } repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED", null, true, request.reason(), true, operatorId, operatorName)); - repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", "WAIT_SPLIT", + repository.insertHistory(history(tenantId, inboundId, "MEASURE_REJECTED", + "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType()) ? "WAIT_PALLETIZE" : "WAIT_SPLIT", "MEASURE_EXCEPTION_RELEASE", request.reason(), operatorId, operatorName)); idempotency.complete(context, inboundId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index 5a0851d0e..4b27d2ceb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -28,7 +28,7 @@ import java.util.stream.Collectors; @Service public class UnifiedInboundQueryService { private static final Set MAIN_STATUSES = Set.of( - "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); @Resource private InboundOrderRepository inbounds; @@ -165,6 +165,7 @@ public class UnifiedInboundQueryService { case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING" -> "WAIT_MEASURE"; case "WAIT_SPLIT" -> "WAIT_SPLIT"; + case "WAIT_PALLETIZE" -> "WAIT_PALLETIZE"; case "LOADING", "LOADED", "DELIVERED" -> status; default -> null; }; @@ -175,6 +176,7 @@ public class UnifiedInboundQueryService { return switch (mainStatus) { case "PENDING_REVIEW" -> "APPROVE_ORDER"; case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null; + case "WAIT_PALLETIZE" -> "PALLETIZE_DROPSHIP"; case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null : "CONFIRMED".equals(preloadStatus) ? null : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; @@ -195,6 +197,7 @@ public class UnifiedInboundQueryService { case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); case "WAIT_SPLIT" -> List.of("WAIT_SPLIT"); + case "WAIT_PALLETIZE" -> List.of("WAIT_PALLETIZE"); case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); default -> List.of("__NO_MATCH__"); }; diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index c248098c9..1c7f191a7 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', inbound_code VARCHAR(40) NOT NULL COMMENT '全平台唯一入仓码', - process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', + process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURE_PENDING待审核、WAIT_PALLETIZE一件代发待合托、WAIT_SPLIT待分柜', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废', @@ -847,3 +847,81 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config ( CONSTRAINT chk_customer_dimension_edge_addition CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000) ) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置'; +-- 一件代发测量完成后进入 WAIT_PALLETIZE,完成合托后才进入 WAIT_SPLIT。 + + +-- 一件代发测量完成后的独立 PDA 合托;不代表国内仓库存入库。 +CREATE TABLE IF NOT EXISTS fl_dropship_pallet_task ( + id BIGINT UNSIGNED NOT NULL COMMENT '合托任务ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '一件代发入仓单ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', + task_status VARCHAR(24) NOT NULL COMMENT 'PENDING/CLAIMED/COMPLETED', + total_cartons INT UNSIGNED NOT NULL COMMENT '测量确认总箱数', + allocated_cartons INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已上托箱数', + assignee_id BIGINT UNSIGNED NULL, + assignee_name VARCHAR(64) NULL, + claimed_at DATETIME(3) NULL, + completed_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_task_inbound (tenant_id,inbound_id), + KEY idx_dropship_task_operator (task_status,assignee_id,warehouse_id,created_at), + CONSTRAINT fk_dropship_task_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id), + CONSTRAINT chk_dropship_task_status CHECK (task_status IN ('PENDING','CLAIMED','COMPLETED')), + CONSTRAINT chk_dropship_task_cartons CHECK (allocated_cartons<=total_cartons) +) ENGINE=InnoDB COMMENT='一件代发测量后PDA合托任务'; + +CREATE TABLE IF NOT EXISTS fl_dropship_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '合托托盘ID', + task_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + warehouse_id BIGINT UNSIGNED NOT NULL, + pallet_no VARCHAR(32) NULL COMMENT '封托时生成的DP托盘号', + pallet_status VARCHAR(16) NOT NULL COMMENT 'OPEN/COMPLETED', + open_operation_id VARCHAR(128) NOT NULL, + seal_operation_id VARCHAR(128) NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + completed_by BIGINT UNSIGNED NULL, + completed_by_name VARCHAR(64) NULL, + completed_at DATETIME(3) NULL, + version INT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_pallet_no (pallet_no), + UNIQUE KEY uk_dropship_pallet_open_op (open_operation_id), + UNIQUE KEY uk_dropship_pallet_seal_op (seal_operation_id), + KEY idx_dropship_pallet_inbound (tenant_id,inbound_id,pallet_status), + CONSTRAINT fk_dropship_pallet_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), + CONSTRAINT chk_dropship_pallet_status CHECK (pallet_status IN ('OPEN','COMPLETED')) +) ENGINE=InnoDB COMMENT='一件代发合托形成的可打印托盘'; + +CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton ( + id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + pallet_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + inbound_id BIGINT UNSIGNED NOT NULL, + inbound_detail_id BIGINT UNSIGNED NOT NULL, + carton_label_id BIGINT UNSIGNED NOT NULL, + sku_code VARCHAR(128) NOT NULL, + cargo_name VARCHAR(255) NULL, + box_code VARCHAR(64) NOT NULL, + operation_id VARCHAR(128) NOT NULL, + bound_by BIGINT UNSIGNED NOT NULL, + bound_by_name VARCHAR(64) NULL, + bound_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dropship_carton_task_label (task_id,carton_label_id), + KEY idx_dropship_carton_pallet_sku (pallet_id,sku_code,id), + KEY idx_dropship_carton_inbound (tenant_id,inbound_id,pallet_id), + KEY idx_dropship_carton_operation (operation_id), + CONSTRAINT fk_dropship_carton_task FOREIGN KEY (task_id) REFERENCES fl_dropship_pallet_task(id), + 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和入仓单明细'; diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml new file mode 100644 index 000000000..764600152 --- /dev/null +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.3 +info: + title: 一件代发 PDA 合托 API + version: 1.0.0 + description: 测量完成后独立下发;不创建国内库存。PDA 扫箱唛识别 SKU,并按箱数合托。 +paths: + /api/platform/v1/first-leg/pda-dropship/tasks/pending: + get: + summary: 查询待领取或本人已领取的一件代发合托任务 + responses: { '200': { description: OK } } + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}: + get: + summary: 查询任务、托盘、入仓单、SKU 和箱数明细 + parameters: + - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: warehouseId, in: query, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: OK } } + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/claim: + post: + summary: 领取合托任务 + parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] + responses: { '200': { description: OK } } + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/open: + post: + summary: 开启一个现场托盘 + parameters: [{ name: taskId, in: path, required: true, schema: { type: integer, format: int64 } }] + responses: { '200': { description: OK } } + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/scan: + post: + summary: 扫箱唛识别 SKU 并填写上托箱数 + parameters: + - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: OK } } + /api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal: + post: + summary: 完成合托并生成 DP 托盘号 + parameters: + - { name: taskId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: OK } } + /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets: + get: + summary: 入仓单查看绑定的一个或多个托盘及各自箱数 + parameters: [{ name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } }] + responses: { '200': { description: OK } } + /api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print: + get: + summary: 打印托盘号、入仓码和 SKU 箱数托盘唛 + parameters: + - { name: inboundId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: + '200': { description: PDF, content: { application/pdf: { schema: { type: string, format: binary } } } } diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 4b8b6c640..fa7dcbb64 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1140,7 +1140,7 @@ components: orderId: {type: integer, format: int64, nullable: true} orderNo: {type: string, nullable: true} inboundCode: {type: string, nullable: true} - processStatus: {type: string, nullable: true, description: Firstleg 内部状态;入库完成记录进入 WAIT_SPLIT} + processStatus: {type: string, nullable: true, description: Firstleg 内部状态;一件代发测量后先进入 WAIT_PALLETIZE,合托完成再进入 WAIT_SPLIT} orderStatusSnapshot: {type: string, nullable: true} measureReviewStatus: {type: string, nullable: true} feeStatus: {type: string, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java index 5fca37ab6..b23203dea 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java @@ -27,6 +27,8 @@ import static org.mockito.Mockito.when; class InboundOrderQueryServiceTest { @MockBean private InboundOrderRepository repository; + @MockBean + private DropshipPalletizationService dropshipPalletization; @Resource private InboundOrderQueryService service; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 54792c723..1554d4785 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -46,6 +46,7 @@ class MeasurementReviewServiceTest { @MockBean private InboundMeasurementRepository measurements; @MockBean private InboundIdempotencyService idempotency; @MockBean private InboundProperties properties; + @MockBean private DropshipPalletizationService dropshipPalletization; @Resource private MeasurementReviewService service; @Test diff --git a/print/src/main/java/com/baiyue/print/dto/InboundMarkPdfDtos.java b/print/src/main/java/com/baiyue/print/dto/InboundMarkPdfDtos.java index cf8452852..ed7a5ad86 100644 --- a/print/src/main/java/com/baiyue/print/dto/InboundMarkPdfDtos.java +++ b/print/src/main/java/com/baiyue/print/dto/InboundMarkPdfDtos.java @@ -26,7 +26,12 @@ public final class InboundMarkPdfDtos { String warehouseText, String cartonText, String routeText, - String qrContent + String qrContent, + String skuCode ) { + public Label(String topCode, String warehouseText, String cartonText, + String routeText, String qrContent) { + this(topCode, warehouseText, cartonText, routeText, qrContent, null); + } } } diff --git a/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java b/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java index 3b98e1e4e..3e0bd8e8d 100644 --- a/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java +++ b/print/src/main/java/com/baiyue/print/renderer/InboundMarkV1Renderer.java @@ -41,6 +41,9 @@ public class InboundMarkV1Renderer { drawCenteredText(stream, units, font, label.warehouseText(), 36, 26); drawCenteredText(stream, units, font, label.cartonText(), 22, 37); drawCenteredText(stream, units, font, label.routeText(), 21, 47); + if (label.skuCode() != null) { + drawCenteredText(stream, units, font, "SKU: " + label.skuCode(), 14, 55); + } qrRenderer.draw(stream, qrMatrix, QR_LEFT_PT, QR_BOTTOM_PT, QR_SIZE_PT); drawCenteredText(stream, units, font, label.qrContent(), 12, 85.5); drawCenteredText(stream, units, font, "MADE IN CHINA", 14, 92); diff --git a/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java b/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java index 4a0e4183e..4376d164b 100644 --- a/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java +++ b/print/src/main/java/com/baiyue/print/service/InboundMarkPdfService.java @@ -110,7 +110,8 @@ public class InboundMarkPdfService { normalizeRequired(label.warehouseText(), field(index, "warehouseText")), normalizeRequired(label.cartonText(), field(index, "cartonText")), normalizeRequired(label.routeText(), field(index, "routeText")), - normalizeQr(label.qrContent(), field(index, "qrContent")))); + normalizeQr(label.qrContent(), field(index, "qrContent")), + normalizeOptional(label.skuCode(), field(index, "skuCode")))); } return new NormalizedRequest(normalizeFileName(request.downloadFileName()), List.copyOf(labels)); } @@ -165,6 +166,14 @@ public class InboundMarkPdfService { return normalized; } + private String normalizeOptional(String value, String field) { + if (value == null || value.isBlank()) return null; + String normalized = Normalizer.normalize(value.trim(), Normalizer.Form.NFC); + if (normalized.codePointCount(0, normalized.length()) > 64) invalid(field + " must not exceed 64 characters"); + normalized.codePoints().forEach(codePoint -> { if (isForbiddenControl(codePoint)) invalid(field + " contains a control character"); }); + return normalized; + } + private boolean isForbiddenControl(int codePoint) { int type = Character.getType(codePoint); return Character.isISOControl(codePoint) diff --git a/print/src/main/resources/openapi/print-internal-v1.yaml b/print/src/main/resources/openapi/print-internal-v1.yaml index d2ab7d478..8ea325df9 100644 --- a/print/src/main/resources/openapi/print-internal-v1.yaml +++ b/print/src/main/resources/openapi/print-internal-v1.yaml @@ -160,6 +160,11 @@ components: minLength: 1 maxLength: 34 pattern: '^[\\x20-\\x7E]+$' + skuCode: + type: string + maxLength: 64 + nullable: true + description: 一件代发箱唛显示的 SKU;二维码仍只使用 qrContent 稳定箱码 FreightA4PdfRequest: type: object required: [templateCode, pageSpec, documents]