一件代发
This commit is contained in:
@@ -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()
|
||||
|
||||
+48
@@ -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<List<Pallet>> 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<Pallet> 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<Pallet> 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<Pallet> 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SkuSummary> skus, List<Carton> cartons) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PalletRow> 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<PalletRow> 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;
|
||||
|
||||
@@ -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<LoadingWorkPalletCartonEntity> {
|
||||
@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<CartonCandidate> 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<LoadingWorkPalletCartonEntity> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<LoadingWorkPalletEntity> {
|
||||
@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<LoadingWorkPalletEntity> 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) {}
|
||||
}
|
||||
+62
-1
@@ -50,13 +50,33 @@ public class LoadingCompletedOutboxService {
|
||||
List<Row> rows = snapshotMapper.rows(planId);
|
||||
if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件");
|
||||
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
||||
List<PalletRow> palletRows = snapshotMapper.palletRows(planId);
|
||||
List<PalletRow> 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<PalletRow> palletRows(long planId) {
|
||||
int workPalletCount = snapshotMapper.workPalletCount(planId);
|
||||
if (snapshotMapper.openWorkPalletCount(planId) > 0) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一件代发存在未封托的现场托盘,不能完成装柜交接");
|
||||
}
|
||||
List<PalletRow> actual = snapshotMapper.actualPalletRows(planId);
|
||||
if (workPalletCount > 0 && actual.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘没有有效箱唛,不能完成装柜交接");
|
||||
}
|
||||
if (actual.isEmpty()) return snapshotMapper.palletRows(planId);
|
||||
Set<InboundPalletKey> actualInbounds = actual.stream().map(row ->
|
||||
new InboundPalletKey(row.getAccountId(), row.getSourceInboundId())).collect(java.util.stream.Collectors.toSet());
|
||||
List<PalletRow> 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<Row> rows, List<PalletRow> palletRows,
|
||||
Map<Long, List<String>> 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<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
|
||||
if (source.isEmpty()) return List.of();
|
||||
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
|
||||
return actualPalletSnapshots(values, source);
|
||||
}
|
||||
Map<String, List<PalletRow>> 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<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
|
||||
List<Row> values, List<PalletRow> source) {
|
||||
Map<Long, Row> details = values.stream().collect(java.util.stream.Collectors.toMap(
|
||||
Row::getSourceInboundDetailId, value -> value, (left, right) -> left, LinkedHashMap::new));
|
||||
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
PalletRow::getPalletCode, LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> result = new ArrayList<>();
|
||||
int actualPackages = 0;
|
||||
for (Map.Entry<String, List<PalletRow>> pallet : byPallet.entrySet()) {
|
||||
Map<Long, List<PalletRow>> byDetail = pallet.getValue().stream().collect(
|
||||
java.util.stream.Collectors.groupingBy(PalletRow::getSourceInboundDetailId,
|
||||
LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
List<FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot> skus = new ArrayList<>();
|
||||
for (Map.Entry<Long, List<PalletRow>> detailRows : byDetail.entrySet()) {
|
||||
Row detail = details.get(detailRows.getKey());
|
||||
if (detail == null) throw new BizException(ResultCode.CONFLICT, "现场托盘箱唛无法匹配装柜货物明细");
|
||||
List<String> 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);
|
||||
}
|
||||
|
||||
@@ -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<CartonCandidate> 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<Pallet> 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<LoadingWorkPalletCartonEntity> values = cartons.selectPalletCartons(pallet.getId());
|
||||
Map<String, List<LoadingWorkPalletCartonEntity>> bySku = values.stream().collect(
|
||||
java.util.stream.Collectors.groupingBy(LoadingWorkPalletCartonEntity::getSkuCode,
|
||||
LinkedHashMap::new, java.util.stream.Collectors.toList()));
|
||||
List<SkuSummary> 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<Carton> 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
+7
@@ -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 {
|
||||
|
||||
+31
@@ -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<InboundOutboxEntity> 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user