From 5f341d6fb5b1b90543b3afcaf6aa4967d4e3adfa Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 27 Aug 2026 17:23:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E4=BB=B6=E4=BB=A3=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 60 +++++ .../PlatformLoadingPdaController.java | 48 ++++ .../firstleg/dto/LoadingPalletDtos.java | 41 ++++ .../entity/LoadingWorkPalletCartonEntity.java | 37 ++++ .../entity/LoadingWorkPalletEntity.java | 36 +++ .../mapper/LoadingCompletedEventMapper.java | 26 ++- .../mapper/LoadingWorkPalletCartonMapper.java | 90 ++++++++ .../mapper/LoadingWorkPalletMapper.java | 77 +++++++ .../LoadingCompletedOutboxService.java | 63 +++++- .../service/LoadingWorkPalletService.java | 206 ++++++++++++++++++ .../db/phase3-smart-loading-schema.sql | 60 +++++ .../openapi/phase3-pda-convergence-v1.yaml | 93 ++++++++ ...tformLoadingPdaControllerContractTest.java | 7 + .../LoadingCompletedOutboxServiceTest.java | 31 +++ .../service/LoadingWorkPalletServiceTest.java | 86 ++++++++ 15 files changed, 959 insertions(+), 2 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index aa7b31e27..5b985324d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5422,6 +5422,66 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', + warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', + pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号', + open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号', + seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号', + pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照', + sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID', + sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照', + sealed_at DATETIME(3) NULL COMMENT '封托时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code), + UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id), + UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id), + KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id), + CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED')) +) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; + +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID', + carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照', + bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID', + bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照', + operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id), + UNIQUE KEY uk_loading_work_carton_operation (operation_id), + KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id), + KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id), + CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id), + CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id), + CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0) +) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; + DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DELIMITER $$ CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java index 269c7c457..07a11095b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingPdaController.java @@ -17,6 +17,11 @@ import java.util.List; import com.baiyue.firstleg.dto.LoadingPickDtos.Task; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingPickService; +import com.baiyue.firstleg.service.LoadingWorkPalletService; +import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -41,6 +46,8 @@ import org.springframework.web.bind.annotation.RestController; public class PlatformLoadingPdaController { @Resource private LoadingPickService service; + @Resource + private LoadingWorkPalletService palletService; @GetMapping("/tasks") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") @@ -164,4 +171,45 @@ public class PlatformLoadingPdaController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.operations(taskId)); } + + @GetMapping("/tasks/{taskId}/pallets") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse> pallets(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.list(taskId, warehouseId)); + } + + @PostMapping("/tasks/{taskId}/pallets/open") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse openPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @Valid @RequestBody OpenRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.open(taskId, request, user.userId(), user.username())); + } + + /** 扫到其他未封托托盘上的箱唛时,同时完成按箱移托。 */ + @PostMapping("/tasks/{taskId}/pallets/{palletId}/cartons/scan") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long palletId, + @Valid @RequestBody ScanRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.scan(taskId, palletId, request, + user.userId(), user.username())); + } + + @PostMapping("/tasks/{taskId}/pallets/{palletId}/seal") + @PreAuthorize("hasAuthority('platform:firstleg:pda:work')") + public ApiResponse sealPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long taskId, + @PathVariable @Positive long palletId, + @Valid @RequestBody SealRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(palletService.seal(taskId, palletId, request, + user.userId(), user.username())); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java new file mode 100644 index 000000000..3bb836838 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPalletDtos.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** 一件代发PDA现场打托接口模型。 */ +public final class LoadingPalletDtos { + private LoadingPalletDtos() {} + + public record OpenRequest( + @NotNull @Min(1) Long warehouseId, + @Min(1) Long warehousePalletId, + @NotBlank @Size(max = 64) String palletCode, + @NotBlank @Size(max = 128) String operationId + ) {} + + public record ScanRequest( + @NotNull @Min(1) Long warehouseId, + @NotBlank @Size(max = 80) String boxCode, + @NotBlank @Size(max = 128) String operationId + ) {} + + public record SealRequest( + @NotNull @Min(1) Long warehouseId, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 128) String operationId + ) {} + + public record Carton(Long id, String boxCode, String skuCode, Long tenantId, + Long inboundId, Long inboundDetailId, Integer unitsPerCarton) {} + + public record SkuSummary(String skuCode, Integer cartonCount, Long productQuantity) {} + + public record Pallet(Long id, String palletCode, Long warehousePalletId, String status, + Integer version, Integer cartonCount, Integer skuCount, + Integer customerCount, List skus, List cartons) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java new file mode 100644 index 000000000..a0072c7bc --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletCartonEntity.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** 一件代发箱唛与现场实际托盘的当前绑定事实。 */ +@Getter +@Setter +@TableName("fl_loading_work_pallet_carton") +public class LoadingWorkPalletCartonEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long workPalletId; + private Long loadingPlanItemId; + private Long pickItemId; + private Long tenantId; + private Long inboundId; + private Long inboundDetailId; + private Long cartonLabelId; + private String boxCode; + private String skuCode; + private Integer unitsPerCarton; + private Long boundBy; + private String boundByName; + private String operationId; + @Version + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java new file mode 100644 index 000000000..169f984e2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/LoadingWorkPalletEntity.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.Version; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** 一件代发装柜现场的实际托盘。 */ +@Getter +@Setter +@TableName("fl_loading_work_pallet") +public class LoadingWorkPalletEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long pickTaskId; + private Long loadingPlanId; + private Long warehouseId; + private Long warehousePalletId; + private String palletCode; + private String openOperationId; + private String sealOperationId; + private String palletStatus; + private Long createdBy; + private String createdByName; + private Long sealedBy; + private String sealedByName; + private LocalDateTime sealedAt; + @Version + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} 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 691cd03d3..45a4cf79c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -48,7 +48,7 @@ public interface LoadingCompletedEventMapper { /** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */ @Select(""" - SELECT i.account_id, i.inbound_id AS source_inbound_id, + SELECT i.account_id, i.inbound_id AS source_inbound_id,0 AS actual_binding, a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.package_count, i.product_quantity, @@ -75,6 +75,29 @@ public interface LoadingCompletedEventMapper { """) List palletRows(@Param("planId") long planId); + /** 一件代发现场逐箱扫描结果;同一实物托盘可以包含多个客户,但行仍保留客户与入仓单归属。 */ + @Select(""" + SELECT carton.tenant_id AS account_id,carton.inbound_id AS source_inbound_id,1 AS actual_binding, + pallet.pallet_code,NULL AS allocation_no, + 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' + 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'") + int workPalletCount(@Param("planId") long planId); + + @Select("SELECT COUNT(*) FROM fl_loading_work_pallet WHERE loading_plan_id=#{planId} AND pallet_status='OPEN'") + int openWorkPalletCount(@Param("planId") long planId); + @Getter @Setter class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; @@ -96,6 +119,7 @@ public interface LoadingCompletedEventMapper { @Getter @Setter class PalletRow { private Long accountId; private Long sourceInboundId; private String palletCode; + private Boolean actualBinding; private Integer allocationNo; private Integer allocatedPackageCount; private Long sourceInboundDetailId; private String skuCode; private String productNameZh; private Integer packageCount; private Integer productQuantity; private String boxCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java new file mode 100644 index 000000000..d8caf8f89 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletCartonMapper.java @@ -0,0 +1,90 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; +import lombok.Getter; +import lombok.Setter; +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.util.List; + +@Mapper +public interface LoadingWorkPalletCartonMapper extends BaseMapper { + @Select(""" + SELECT label.id carton_label_id,label.tenant_id,label.inbound_id,label.inbound_detail_id, + label.box_code,plan_item.id loading_plan_item_id,pick_item.id pick_item_id, + plan_item.sku_code,plan_item.units_per_carton + FROM fl_inbound_carton_label label + JOIN fl_loading_plan_item plan_item + ON plan_item.account_id=label.tenant_id AND plan_item.inbound_id=label.inbound_id + AND plan_item.inbound_detail_id=label.inbound_detail_id AND plan_item.deleted=0 + JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id + JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id + AND task.loading_plan_id=plan_item.loading_plan_id + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id + AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0 + WHERE task.id=#{taskId} AND task.task_status='LOADING' AND booking.warehouse_id=#{warehouseId} + AND label.box_code=#{boxCode} AND label.batch_status='ACTIVE' + AND inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' + AND pick_item.item_status<>'SKIPPED' + ORDER BY label.tenant_id,label.id + """) + List selectCandidates(@Param("taskId") long taskId, + @Param("warehouseId") long warehouseId, + @Param("boxCode") String boxCode); + + @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE pick_task_id=#{taskId} AND carton_label_id=#{labelId}") + LoadingWorkPalletCartonEntity selectBinding(@Param("taskId") long taskId, + @Param("labelId") long labelId); + + @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE operation_id=#{operationId}") + LoadingWorkPalletCartonEntity selectByOperation(@Param("operationId") String operationId); + + @Select("SELECT * FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId} ORDER BY id") + List selectPalletCartons(@Param("palletId") long palletId); + + @Select("SELECT COUNT(*) FROM fl_loading_work_pallet_carton WHERE work_pallet_id=#{palletId}") + int countPalletCartons(@Param("palletId") long palletId); + + @Select(""" + SELECT COUNT(*) FROM ( + SELECT carton.pick_item_id + FROM fl_loading_work_pallet_carton carton + JOIN fl_loading_pick_item pick_item ON pick_item.id=carton.pick_item_id + WHERE carton.pick_task_id=#{taskId} + GROUP BY carton.pick_item_id,pick_item.picked_package_count + HAVING COUNT(*)>pick_item.picked_package_count + ) unreconciled + """) + int countUnreconciled(@Param("taskId") long taskId); + + @Update(""" + UPDATE fl_loading_work_pallet_carton + SET work_pallet_id=#{targetPalletId},bound_by=#{operatorId},bound_by_name=#{operatorName}, + operation_id=#{operationId},version=version+1 + WHERE id=#{id} AND version=#{version} + """) + int move(@Param("id") long id, @Param("version") int version, + @Param("targetPalletId") long targetPalletId, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName, + @Param("operationId") String operationId); + + @Getter + @Setter + class CartonCandidate { + private Long cartonLabelId; + private Long tenantId; + private Long inboundId; + private Long inboundDetailId; + private String boxCode; + private Long loadingPlanItemId; + private Long pickItemId; + private String skuCode; + private Integer unitsPerCarton; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java new file mode 100644 index 000000000..e1b6ba90c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingWorkPalletMapper.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; +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.util.List; + +@Mapper +public interface LoadingWorkPalletMapper extends BaseMapper { + @Select(""" + SELECT task.id task_id,task.loading_plan_id,task.task_status,booking.warehouse_id, + assignment.assignee_id, + SUM(CASE WHEN inbound_order.fulfillment_type='ONE_CLICK_DROPSHIP' THEN 1 ELSE 0 END) dropship_item_count + FROM fl_loading_pick_task task + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id=task.id + JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id AND plan_item.deleted=0 + JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id + AND inbound_order.tenant_id=plan_item.account_id AND inbound_order.deleted=0 + LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id + WHERE task.id=#{taskId} AND booking.warehouse_id=#{warehouseId} + GROUP BY task.id,task.loading_plan_id,task.task_status,booking.warehouse_id,assignment.assignee_id + """) + TaskContext selectTaskContext(@Param("taskId") long taskId, @Param("warehouseId") long warehouseId); + + @Select("SELECT * FROM fl_loading_work_pallet WHERE open_operation_id=#{operationId}") + LoadingWorkPalletEntity selectByOpenOperation(@Param("operationId") String operationId); + + @Select("SELECT * FROM fl_loading_work_pallet WHERE seal_operation_id=#{operationId}") + LoadingWorkPalletEntity selectBySealOperation(@Param("operationId") String operationId); + + @Select(""" + SELECT pallet.* + FROM fl_loading_work_pallet pallet + JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + WHERE pallet.pick_task_id=#{taskId} AND pallet.id=#{palletId} + AND booking.warehouse_id=#{warehouseId} + """) + LoadingWorkPalletEntity selectScoped(@Param("taskId") long taskId, + @Param("palletId") long palletId, + @Param("warehouseId") long warehouseId); + + @Select(""" + SELECT pallet.* + FROM fl_loading_work_pallet pallet + JOIN fl_loading_pick_task task ON task.id=pallet.pick_task_id + JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + WHERE pallet.pick_task_id=#{taskId} AND booking.warehouse_id=#{warehouseId} + AND pallet.pallet_status<>'CANCELLED' + ORDER BY pallet.id + """) + List selectTaskPallets(@Param("taskId") long taskId, + @Param("warehouseId") long warehouseId); + + @Update(""" + UPDATE fl_loading_work_pallet + SET pallet_status='SEALED',seal_operation_id=#{operationId},sealed_by=#{operatorId}, + sealed_by_name=#{operatorName},sealed_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{palletId} AND pick_task_id=#{taskId} AND warehouse_id=#{warehouseId} + AND pallet_status='OPEN' AND version=#{expectedVersion} + """) + int seal(@Param("taskId") long taskId, @Param("palletId") long palletId, + @Param("warehouseId") long warehouseId, @Param("expectedVersion") int expectedVersion, + @Param("operationId") String operationId, @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + record TaskContext(Long taskId, Long loadingPlanId, String taskStatus, Long warehouseId, + Long assigneeId, Integer dropshipItemCount) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index d2c706779..7131ab519 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -50,13 +50,33 @@ public class LoadingCompletedOutboxService { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); Map> cartonMarks = cartonLabelPools(rows); - List palletRows = snapshotMapper.palletRows(planId); + List palletRows = palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows, palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList(), cartonMarks))); } + /** 已开展逐箱打托的入仓单使用现场事实;未开展的存量入仓单继续兼容原托盘分配快照。 */ + private List palletRows(long planId) { + int workPalletCount = snapshotMapper.workPalletCount(planId); + if (snapshotMapper.openWorkPalletCount(planId) > 0) { + throw new BizException(ResultCode.CONFLICT, "一件代发存在未封托的现场托盘,不能完成装柜交接"); + } + List actual = snapshotMapper.actualPalletRows(planId); + if (workPalletCount > 0 && actual.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘没有有效箱唛,不能完成装柜交接"); + } + if (actual.isEmpty()) return snapshotMapper.palletRows(planId); + Set actualInbounds = actual.stream().map(row -> + new InboundPalletKey(row.getAccountId(), row.getSourceInboundId())).collect(java.util.stream.Collectors.toSet()); + List result = new ArrayList<>(actual); + snapshotMapper.palletRows(planId).stream() + .filter(row -> !actualInbounds.contains(new InboundPalletKey(row.getAccountId(), row.getSourceInboundId()))) + .forEach(result::add); + return result; + } + private EventEnvelope envelope(long accountId, List rows, List palletRows, Map> cartonMarks) { Row first = rows.get(0); @@ -182,9 +202,13 @@ public class LoadingCompletedOutboxService { } private record LabelPoolKey(long accountId, long inboundId) { } + private record InboundPalletKey(long accountId, long inboundId) { } private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); + if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) { + return actualPalletSnapshots(values, source); + } Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), LinkedHashMap::new, java.util.stream.Collectors.toList())); @@ -235,6 +259,43 @@ public class LoadingCompletedOutboxService { return result; } + /** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */ + private List actualPalletSnapshots( + List values, List source) { + Map details = values.stream().collect(java.util.stream.Collectors.toMap( + Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new)); + Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( + PalletRow::getPalletCode, LinkedHashMap::new, java.util.stream.Collectors.toList())); + List result = new ArrayList<>(); + int actualPackages = 0; + for (Map.Entry> pallet : byPallet.entrySet()) { + Map> byDetail = pallet.getValue().stream().collect( + java.util.stream.Collectors.groupingBy(PalletRow::getSourceInboundDetailId, + LinkedHashMap::new, java.util.stream.Collectors.toList())); + List skus = new ArrayList<>(); + for (Map.Entry> detailRows : byDetail.entrySet()) { + Row detail = details.get(detailRows.getKey()); + if (detail == null) throw new BizException(ResultCode.CONFLICT, "现场托盘箱唛无法匹配装柜货物明细"); + List marks = detailRows.getValue().stream().map(PalletRow::getBoxCode) + .filter(Objects::nonNull).distinct().toList(); + if (marks.size() != detailRows.getValue().size()) { + throw new BizException(ResultCode.CONFLICT, "现场托盘包含空箱唛或重复箱唛"); + } + int quantity = detail.getProductQuantity() == null ? marks.size() + : (int) Math.round((double) detail.getProductQuantity() * marks.size() + / Math.max(1, detail.getPackageCount())); + skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSkuCode(), + detail.getProductNameZh(), marks.size(), quantity, marks)); + actualPackages += marks.size(); + } + result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus)); + } + if (actualPackages != sumInt(values, Row::getPackageCount)) { + throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致"); + } + return result; + } + private static boolean dropship(String value) { return "ONE_CLICK_DROPSHIP".equals(value); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java new file mode 100644 index 000000000..4557f92ce --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java @@ -0,0 +1,206 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.LoadingPalletDtos.Carton; +import com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.Pallet; +import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SkuSummary; +import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; +import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** 一件代发现场打托旁路;不改变既有拣货数量和仓库资源结算。 */ +@Service +public class LoadingWorkPalletService { + @Resource private LoadingWorkPalletMapper pallets; + @Resource private LoadingWorkPalletCartonMapper cartons; + + @Transactional + public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) { + TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId); + String code = request.palletCode().trim(); + String operationId = request.operationId().trim(); + LoadingWorkPalletEntity replay = pallets.selectByOpenOperation(operationId); + if (replay != null) { + if (replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) { + throw conflict("开托幂等号已用于其他请求"); + } + return view(replay); + } + LoadingWorkPalletEntity value = new LoadingWorkPalletEntity(); + value.setPickTaskId(taskId); + value.setLoadingPlanId(context.loadingPlanId()); + value.setWarehouseId(request.warehouseId()); + value.setWarehousePalletId(request.warehousePalletId()); + value.setPalletCode(code); + value.setOpenOperationId(operationId); + value.setPalletStatus("OPEN"); + value.setCreatedBy(operatorId); + value.setCreatedByName(operatorName); + value.setVersion(0); + try { + pallets.insert(value); + } catch (DuplicateKeyException exception) { + replay = pallets.selectByOpenOperation(operationId); + if (replay == null || replay.getPickTaskId() != taskId || !replay.getPalletCode().equals(code)) { + throw conflict("托盘号已存在或开托操作重复"); + } + value = replay; + } + return view(value); + } + + @Transactional + public Pallet scan(long taskId, long palletId, ScanRequest request, + long operatorId, String operatorName) { + requireWorkTask(taskId, request.warehouseId(), operatorId); + LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId()); + String operationId = request.operationId().trim(); + LoadingWorkPalletCartonEntity replay = cartons.selectByOperation(operationId); + if (replay != null) { + if (replay.getPickTaskId() != taskId || replay.getWorkPalletId() != palletId + || !replay.getBoxCode().equals(request.boxCode().trim())) { + throw conflict("箱唛操作幂等号已用于其他请求"); + } + return view(target); + } + List candidates = cartons.selectCandidates(taskId, request.warehouseId(), + request.boxCode().trim()); + if (candidates.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "箱唛不属于当前任务的一件代发货物"); + if (candidates.size() > 1) throw conflict("箱唛在当前多客户任务中不唯一,请检查箱唛数据"); + CartonCandidate candidate = candidates.get(0); + if (candidate.getSkuCode() == null || candidate.getSkuCode().isBlank()) { + throw conflict("一件代发箱唛缺少SKU,不能上托"); + } + LoadingWorkPalletCartonEntity current = cartons.selectBinding(taskId, candidate.getCartonLabelId()); + if (current == null) { + current = binding(taskId, palletId, candidate, operationId, operatorId, operatorName); + try { + cartons.insert(current); + } catch (DuplicateKeyException exception) { + LoadingWorkPalletCartonEntity concurrent = cartons.selectBinding(taskId, candidate.getCartonLabelId()); + if (concurrent == null || concurrent.getWorkPalletId() != palletId) { + throw conflict("箱唛已被其他托盘绑定"); + } + } + return view(target); + } + if (current.getWorkPalletId() == palletId) return view(target); + LoadingWorkPalletEntity source = pallets.selectScoped(taskId, current.getWorkPalletId(), request.warehouseId()); + if (source == null || !"OPEN".equals(source.getPalletStatus())) { + throw conflict("来源托盘已封托,不能直接移箱"); + } + if (cartons.move(current.getId(), current.getVersion(), palletId, operatorId, operatorName, operationId) != 1) { + throw conflict("箱唛绑定已变化,请刷新后重试"); + } + return view(target); + } + + @Transactional + public Pallet seal(long taskId, long palletId, SealRequest request, + long operatorId, String operatorName) { + requireWorkTask(taskId, request.warehouseId(), operatorId); + LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim()); + if (replay != null) { + if (replay.getId() != palletId || replay.getPickTaskId() != taskId) { + throw conflict("封托幂等号已用于其他请求"); + } + return view(replay); + } + requireOpenPallet(taskId, palletId, request.warehouseId()); + if (cartons.countPalletCartons(palletId) == 0) throw conflict("空托盘不能封托"); + if (cartons.countUnreconciled(taskId) > 0) { + throw conflict("现场上托箱数超过已提交拣货箱数,请先完成原PDA拣货提交"); + } + if (pallets.seal(taskId, palletId, request.warehouseId(), request.expectedVersion(), + request.operationId().trim(), operatorId, operatorName) != 1) { + throw conflict("托盘状态或版本已变化,请刷新后重试"); + } + return view(pallets.selectScoped(taskId, palletId, request.warehouseId())); + } + + @Transactional(readOnly = true) + public List list(long taskId, long warehouseId) { + TaskContext context = pallets.selectTaskContext(taskId, warehouseId); + if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务"); + return pallets.selectTaskPallets(taskId, warehouseId).stream().map(this::view).toList(); + } + + private TaskContext requireWorkTask(long taskId, long warehouseId, long operatorId) { + TaskContext context = pallets.selectTaskContext(taskId, warehouseId); + if (context == null) throw new BizException(ResultCode.NOT_FOUND, "未找到当前仓库的PDA任务"); + if (!"LOADING".equals(context.taskStatus())) throw conflict("PDA任务已完成,不能继续打托"); + if (context.dropshipItemCount() == null || context.dropshipItemCount() == 0) { + throw conflict("当前任务没有一件代发货物"); + } + if (context.assigneeId() == null || context.assigneeId() != operatorId) { + throw conflict("请先领取PDA任务后再执行打托"); + } + return context; + } + + private LoadingWorkPalletEntity requireOpenPallet(long taskId, long palletId, long warehouseId) { + LoadingWorkPalletEntity pallet = pallets.selectScoped(taskId, palletId, warehouseId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "现场托盘不存在"); + if (!"OPEN".equals(pallet.getPalletStatus())) throw conflict("托盘不是打托中状态"); + return pallet; + } + + private static LoadingWorkPalletCartonEntity binding(long taskId, long palletId, + CartonCandidate source, String operationId, + long operatorId, String operatorName) { + LoadingWorkPalletCartonEntity value = new LoadingWorkPalletCartonEntity(); + value.setPickTaskId(taskId); + value.setWorkPalletId(palletId); + value.setLoadingPlanItemId(source.getLoadingPlanItemId()); + value.setPickItemId(source.getPickItemId()); + value.setTenantId(source.getTenantId()); + value.setInboundId(source.getInboundId()); + value.setInboundDetailId(source.getInboundDetailId()); + value.setCartonLabelId(source.getCartonLabelId()); + value.setBoxCode(source.getBoxCode()); + value.setSkuCode(source.getSkuCode()); + value.setUnitsPerCarton(source.getUnitsPerCarton()); + value.setBoundBy(operatorId); + value.setBoundByName(operatorName); + value.setOperationId(operationId); + value.setVersion(0); + return value; + } + + private Pallet view(LoadingWorkPalletEntity pallet) { + List values = cartons.selectPalletCartons(pallet.getId()); + Map> bySku = values.stream().collect( + java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode, + LinkedHashMap::new, java.util.stream.Collectors.toList())); + List skus = bySku.entrySet().stream().map(entry -> new SkuSummary(entry.getKey(), + entry.getValue().size(), entry.getValue().stream().map(LoadingWorkPalletCartonEntity::getUnitsPerCarton) + .filter(Objects::nonNull).mapToLong(Integer::longValue).sum())).toList(); + List cartonViews = values.stream().map(value -> new Carton(value.getId(), value.getBoxCode(), + value.getSkuCode(), value.getTenantId(), value.getInboundId(), value.getInboundDetailId(), + value.getUnitsPerCarton())).toList(); + return new Pallet(pallet.getId(), pallet.getPalletCode(), pallet.getWarehousePalletId(), + pallet.getPalletStatus(), pallet.getVersion(), values.size(), bySku.size(), + (int) values.stream().map(LoadingWorkPalletCartonEntity::getTenantId).distinct().count(), + skus, cartonViews); + } + + private static BizException conflict(String message) { + return new BizException(ResultCode.CONFLICT, message); + } +} diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 5033d5924..ff90ac11e 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -623,6 +623,66 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场作业托盘ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '作业仓库ID', + warehouse_pallet_id BIGINT UNSIGNED NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', + pallet_code VARCHAR(64) NOT NULL COMMENT '现场托盘号', + open_operation_id VARCHAR(128) NOT NULL COMMENT '开托幂等操作号', + seal_operation_id VARCHAR(128) NULL COMMENT '封托幂等操作号', + pallet_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建操作人名称快照', + sealed_by BIGINT UNSIGNED NULL COMMENT '封托操作人ID', + sealed_by_name VARCHAR(64) NULL COMMENT '封托操作人名称快照', + sealed_at DATETIME(3) NULL COMMENT '封托时间', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_pallet_code (pick_task_id, pallet_code), + UNIQUE KEY uk_loading_work_pallet_open_operation (open_operation_id), + UNIQUE KEY uk_loading_work_pallet_seal_operation (seal_operation_id), + KEY idx_loading_work_pallet_plan (loading_plan_id, pallet_status, id), + CONSTRAINT fk_loading_work_pallet_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_pallet_plan FOREIGN KEY (loading_plan_id) REFERENCES fl_loading_plan (id), + CONSTRAINT chk_loading_work_pallet_status CHECK (pallet_status IN ('OPEN','SEALED','CANCELLED')) +) ENGINE=InnoDB COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; + +CREATE TABLE IF NOT EXISTS fl_loading_work_pallet_carton ( + id BIGINT UNSIGNED NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA装柜拣货任务ID', + work_pallet_id BIGINT UNSIGNED NOT NULL COMMENT '现场作业托盘ID', + loading_plan_item_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划货物明细ID', + pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '箱唛所属客户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID', + inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓货物明细ID', + carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '稳定箱唛记录ID', + box_code VARCHAR(80) NOT NULL COMMENT '稳定箱唛箱码快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', + units_per_carton INT UNSIGNED NULL COMMENT '每箱产品数量快照', + bound_by BIGINT UNSIGNED NOT NULL COMMENT '最后绑定操作人ID', + bound_by_name VARCHAR(64) NULL COMMENT '最后绑定操作人名称快照', + operation_id VARCHAR(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_work_carton_task_label (pick_task_id, carton_label_id), + UNIQUE KEY uk_loading_work_carton_operation (operation_id), + KEY idx_loading_work_carton_pallet (work_pallet_id, sku_code, id), + KEY idx_loading_work_carton_plan_item (loading_plan_item_id, id), + CONSTRAINT fk_loading_work_carton_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id), + CONSTRAINT fk_loading_work_carton_pallet FOREIGN KEY (work_pallet_id) REFERENCES fl_loading_work_pallet (id), + CONSTRAINT fk_loading_work_carton_plan_item FOREIGN KEY (loading_plan_item_id) REFERENCES fl_loading_plan_item (id), + CONSTRAINT fk_loading_work_carton_pick_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id), + CONSTRAINT fk_loading_work_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label (id), + CONSTRAINT chk_loading_work_carton_units CHECK (units_per_carton IS NULL OR units_per_carton > 0) +) ENGINE=InnoDB COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; + DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DELIMITER $$ CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 165d36799..995e50d79 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -119,11 +119,104 @@ paths: parameters: [{$ref: '#/components/parameters/TaskId'}] responses: '200': {description: Ordered operation history} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets: + get: + summary: 查询一件代发现场实际托盘、SKU汇总和逐箱明细 + parameters: + - {$ref: '#/components/parameters/TaskId'} + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + responses: + '200': + description: 当前任务未取消的现场托盘 + content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/WorkPallet'}}}} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/open: + post: + summary: 为已领取任务开启一个一件代发现场托盘 + parameters: [{$ref: '#/components/parameters/TaskId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/OpenPalletRequest'}}} + responses: + '200': {description: 已开启托盘或相同operationId的幂等结果} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/cartons/scan: + post: + summary: 扫描一件代发箱唛上托;箱唛已在其他未封托托盘时按箱移托 + description: 本操作只记录实际箱到托关系,不推进既有拣货数量、订单状态或仓库资源结算。 + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ScanCartonRequest'}}} + responses: + '200': {description: 包含最新SKU与箱唛汇总的目标托盘} + /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/pallets/{palletId}/seal: + post: + summary: 封托并校验现场上托箱数未超过既有PDA已提交拣货箱数 + parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/PalletId'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}} + responses: + '200': {description: 已封托托盘或A0409数量/版本冲突} components: parameters: TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} ItemId: {name: itemId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + PalletId: {name: palletId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} schemas: + OpenPalletRequest: + type: object + required: [warehouseId, palletCode, operationId] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + warehousePalletId: {type: integer, format: int64, minimum: 1, nullable: true} + palletCode: {type: string, maxLength: 64} + operationId: {type: string, maxLength: 128} + ScanCartonRequest: + type: object + required: [warehouseId, boxCode, operationId] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + boxCode: {type: string, maxLength: 80} + operationId: {type: string, maxLength: 128} + SealPalletRequest: + type: object + required: [warehouseId, expectedVersion, operationId] + properties: + warehouseId: {type: integer, format: int64, minimum: 1} + expectedVersion: {type: integer, minimum: 0} + operationId: {type: string, maxLength: 128} + WorkPallet: + type: object + required: [id, palletCode, status, version, cartonCount, skuCount, customerCount, skus, cartons] + properties: + id: {type: integer, format: int64} + palletCode: {type: string} + warehousePalletId: {type: integer, format: int64, nullable: true} + status: {type: string, enum: [OPEN, SEALED]} + version: {type: integer, minimum: 0} + cartonCount: {type: integer, minimum: 0} + skuCount: {type: integer, minimum: 0} + customerCount: {type: integer, minimum: 0} + skus: {type: array, items: {$ref: '#/components/schemas/WorkPalletSku'}} + cartons: {type: array, items: {$ref: '#/components/schemas/WorkPalletCarton'}} + WorkPalletSku: + type: object + required: [skuCode, cartonCount, productQuantity] + properties: + skuCode: {type: string} + cartonCount: {type: integer, minimum: 1} + productQuantity: {type: integer, format: int64, minimum: 0} + WorkPalletCarton: + type: object + required: [id, boxCode, skuCode, tenantId, inboundId, inboundDetailId] + properties: + id: {type: integer, format: int64} + boxCode: {type: string} + skuCode: {type: string} + tenantId: {type: integer, format: int64} + inboundId: {type: integer, format: int64} + inboundDetailId: {type: integer, format: int64} + unitsPerCarton: {type: integer, minimum: 1, nullable: true} Task: type: object required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java index 553b49f95..6d477e85e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformLoadingPdaControllerContractTest.java @@ -24,6 +24,13 @@ class PlatformLoadingPdaControllerContractTest { authority("review", "platform:firstleg:pda:review", AuthenticatedUser.class, long.class, ReviewRequest.class); authority("complete", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, CompleteLoadingRequest.class); authority("operations", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class); + authority("pallets", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class); + authority("openPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, + com.baiyue.firstleg.dto.LoadingPalletDtos.OpenRequest.class); + authority("scanCarton", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, + com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest.class); + authority("sealPallet", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, long.class, + com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest.class); } private void authority(String name, String expected, Class... parameterTypes) throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 50ff2452d..f68b252fc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -187,6 +187,30 @@ class LoadingCompletedOutboxServiceTest { assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); } + @Test void publishesExactPdaCartonToPalletBindingsWithoutSequentialRedistribution() throws Exception { + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); + first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); + Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + second.setPackageCount(2); second.setProductQuantity(10); second.setSkuCode("SKU-B"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.workPalletCount(90L)).thenReturn(2); + when(snapshots.actualPalletRows(90L)).thenReturn(List.of( + actualPallet(11,100,"PLT-01",101,"SKU-A","BOX-A2"), + actualPallet(11,100,"PLT-01",102,"SKU-B","BOX-B1"), + actualPallet(11,100,"PLT-02",101,"SKU-A","BOX-A1"), + actualPallet(11,100,"PLT-02",102,"SKU-B","BOX-B2"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks/0").asText()) + .isEqualTo("BOX-A2"); + assertThat(event.payload().at("/orders/0/pallets/1/skuPackages/0/cartonMarks/0").asText()) + .isEqualTo("BOX-A1"); + } + @Test void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception { Row value = row(11, 100, 101); @@ -238,6 +262,13 @@ class LoadingCompletedOutboxServiceTest { row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; } + private PalletRow actualPallet(long account,long inbound,String palletNo,long detail, + String sku,String boxCode) { + PalletRow row = pallet(account,inbound,0,palletNo,1,detail,sku,1,1,boxCode); + row.setActualBinding(true); + return row; + } + private LabelRow label(long detailId, String boxCode) { LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java new file mode 100644 index 000000000..c48acc2e2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java @@ -0,0 +1,86 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.LoadingPalletDtos.ScanRequest; +import com.baiyue.firstleg.dto.LoadingPalletDtos.SealRequest; +import com.baiyue.firstleg.entity.LoadingWorkPalletCartonEntity; +import com.baiyue.firstleg.entity.LoadingWorkPalletEntity; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletCartonMapper.CartonCandidate; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper; +import com.baiyue.firstleg.mapper.LoadingWorkPalletMapper.TaskContext; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(LoadingWorkPalletService.class) +class LoadingWorkPalletServiceTest { + @MockBean private LoadingWorkPalletMapper pallets; + @MockBean private LoadingWorkPalletCartonMapper cartons; + @Resource private LoadingWorkPalletService service; + + @Test + void scansDropshipCartonOntoMixedCustomerWorkPalletWithoutChangingPickWorkflow() { + when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); + LoadingWorkPalletEntity pallet = pallet(30L, "OPEN"); + when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet); + CartonCandidate candidate = candidate(); + when(cartons.selectCandidates(10L, 20L, "BOX-A")).thenReturn(List.of(candidate)); + doAnswer(invocation -> { + LoadingWorkPalletCartonEntity value = invocation.getArgument(0); + value.setId(50L); + return 1; + }).when(cartons).insert(any(LoadingWorkPalletCartonEntity.class)); + when(cartons.selectPalletCartons(30L)).thenAnswer(invocation -> List.of()); + + service.scan(10L, 30L, new ScanRequest(20L, "BOX-A", "SCAN-1"), 99L, "作业员"); + + var captor = org.mockito.ArgumentCaptor.forClass(LoadingWorkPalletCartonEntity.class); + verify(cartons).insert(captor.capture()); + assertThat(captor.getValue().getTenantId()).isEqualTo(88L); + assertThat(captor.getValue().getLoadingPlanItemId()).isEqualTo(41L); + assertThat(captor.getValue().getSkuCode()).isEqualTo("SKU-A"); + } + + @Test + void refusesToSealUntilScannedCartonsAreReconciledByExistingPickSubmission() { + when(pallets.selectTaskContext(10L, 20L)).thenReturn(context()); + when(pallets.selectScoped(10L, 30L, 20L)).thenReturn(pallet(30L, "OPEN")); + when(cartons.countPalletCartons(30L)).thenReturn(2); + when(cartons.countUnreconciled(10L)).thenReturn(1); + + assertThatThrownBy(() -> service.seal(10L, 30L, + new SealRequest(20L, 0, "SEAL-1"), 99L, "作业员")) + .hasMessageContaining("原PDA拣货提交"); + } + + private static TaskContext context() { + return new TaskContext(10L, 11L, "LOADING", 20L, 99L, 2); + } + + private static LoadingWorkPalletEntity pallet(long id, String status) { + LoadingWorkPalletEntity value = new LoadingWorkPalletEntity(); + value.setId(id); value.setPickTaskId(10L); value.setLoadingPlanId(11L); + value.setWarehouseId(20L); value.setPalletCode("PLT-01"); + value.setPalletStatus(status); value.setVersion(0); + return value; + } + + private static CartonCandidate candidate() { + CartonCandidate value = new CartonCandidate(); + value.setCartonLabelId(40L); value.setTenantId(88L); value.setInboundId(81L); + value.setInboundDetailId(82L); value.setBoxCode("BOX-A"); + value.setLoadingPlanItemId(41L); value.setPickItemId(42L); + value.setSkuCode("SKU-A"); value.setUnitsPerCarton(20); + return value; + } +}