Merge branch 'dev-lyhfive' of http://git.buggoglobal.com/java-project/baiyue-firstleg into dev
This commit is contained in:
@@ -63,7 +63,12 @@ public interface OrderCargoClient {
|
||||
}
|
||||
|
||||
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
|
||||
String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
|
||||
BigDecimal heightCm) {
|
||||
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
|
||||
BigDecimal heightCm) {
|
||||
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -4,8 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.firstleg.service.OverseasContainerEventConsumer;
|
||||
import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -20,14 +21,17 @@ import java.security.MessageDigest;
|
||||
@RequiredArgsConstructor
|
||||
public class InternalOverseasContainerEventController {
|
||||
private final OverseasContainerEventConsumer consumer;
|
||||
private final OverseasInboundOrderDetainedConsumer detainedConsumer;
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
|
||||
@PostMapping("/internal/firstleg/v1/container-events")
|
||||
public ApiResponse<ContainerBookingEntity> receive(
|
||||
public ApiResponse<Object> receive(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope event) {
|
||||
authenticate(token);
|
||||
return ApiResponse.success(consumer.consume(event));
|
||||
Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
|
||||
? detainedConsumer.consume(event) : consumer.consume(event);
|
||||
return ApiResponse.success(result);
|
||||
}
|
||||
|
||||
private void authenticate(String token) {
|
||||
|
||||
+48
@@ -24,6 +24,11 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
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;
|
||||
@@ -51,6 +56,8 @@ public class PlatformLoadingPdaController {
|
||||
private LoadingPickService service;
|
||||
@Resource
|
||||
private CabinetLoadingEvidenceService evidenceService;
|
||||
@Resource
|
||||
private LoadingWorkPalletService palletService;
|
||||
|
||||
@GetMapping("/tasks")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
@@ -218,4 +225,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) {}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public class InboundCartonLabelEntity {
|
||||
private Long inboundDetailId;
|
||||
private Integer cargoItemNo;
|
||||
private String cargoName;
|
||||
private String skuCode;
|
||||
private Integer detailPackageCount;
|
||||
private String inboundCode;
|
||||
private String boxCode;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_inbound_hold_record")
|
||||
public class InboundHoldRecordEntity {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
private String holdAction;
|
||||
private String reason;
|
||||
private Long evidenceFileId;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private LocalDateTime operatedAt;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ public class InboundOrderEntity extends BaseEntity {
|
||||
private String destinationName;
|
||||
private Boolean holdFlag;
|
||||
private String currentHoldReason;
|
||||
private Long lastOverseasHoldVersion;
|
||||
private Integer totalPackageCount;
|
||||
private BigDecimal actualGrossWeightKg;
|
||||
private BigDecimal chargeableWeightKg;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InboundHoldRecordMapper extends BaseMapper<InboundHoldRecordEntity> {
|
||||
}
|
||||
@@ -5,10 +5,25 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/** 入库聚合根条件更新 Mapper,所有推进均同时校验租户、前置状态和版本。 */
|
||||
@Mapper
|
||||
public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
|
||||
@Select("SELECT * FROM fl_inbound_order WHERE id=#{inboundId} AND deleted=0 FOR UPDATE")
|
||||
InboundOrderEntity selectActiveByIdForUpdate(@Param("inboundId") long inboundId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET hold_flag=#{detained}, current_hold_reason=#{reason},
|
||||
last_overseas_hold_version=#{sourceVersion}, updated_by=#{operatorId}, version=version+1
|
||||
WHERE id=#{inboundId} AND tenant_id=#{tenantId} AND deleted=0
|
||||
AND (last_overseas_hold_version IS NULL OR last_overseas_hold_version < #{sourceVersion})
|
||||
""")
|
||||
int applyOverseasHold(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("detained") boolean detained, @Param("reason") String reason,
|
||||
@Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order
|
||||
SET process_status = 'WAIT_SPLIT', measure_review_status = 'APPROVED',
|
||||
|
||||
@@ -50,11 +50,11 @@ public interface LoadingCompletedEventMapper {
|
||||
""")
|
||||
List<Row> rows(@Param("planId") long planId);
|
||||
|
||||
/** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */
|
||||
/** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */
|
||||
@Select("""
|
||||
SELECT i.account_id, i.inbound_id AS source_inbound_id,
|
||||
a.pallet_id, a.reservation_id, a.pallet_code, a.allocation_no,
|
||||
a.picked_package_count AS allocated_package_count,
|
||||
0 AS actual_binding, 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,
|
||||
label.box_code
|
||||
@@ -80,6 +80,30 @@ 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,
|
||||
pallet.id AS pallet_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;
|
||||
@@ -105,6 +129,7 @@ public interface LoadingCompletedEventMapper {
|
||||
class PalletRow {
|
||||
private Long accountId; private Long sourceInboundId; private Long palletId;
|
||||
private Long reservationId; 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) {}
|
||||
}
|
||||
+10
@@ -62,6 +62,16 @@ public class InboundCartonLabelRepository {
|
||||
.set(InboundCartonLabelEntity::getCargoFingerprint, fingerprint));
|
||||
}
|
||||
|
||||
public void initializeLegacySku(long tenantId, long inboundId, long inboundDetailId, String skuCode) {
|
||||
if (skuCode == null || skuCode.isBlank()) return;
|
||||
labels.update(null, new LambdaUpdateWrapper<InboundCartonLabelEntity>()
|
||||
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
|
||||
.eq(InboundCartonLabelEntity::getInboundId, inboundId)
|
||||
.eq(InboundCartonLabelEntity::getInboundDetailId, inboundDetailId)
|
||||
.isNull(InboundCartonLabelEntity::getSkuCode)
|
||||
.set(InboundCartonLabelEntity::getSkuCode, skuCode.trim()));
|
||||
}
|
||||
|
||||
public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, tenantId)
|
||||
|
||||
@@ -50,6 +50,14 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
|
||||
}
|
||||
List<OrderCargoClient.CargoLabelBinding> cargoes = loadCargoes(inbound);
|
||||
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
|
||||
&& cargoes.stream().anyMatch(cargo -> cargo.skuNo() == null || cargo.skuNo().isBlank())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发货物明细缺少SKU,不能生成箱唛");
|
||||
}
|
||||
existing.forEach(label -> cargoes.stream()
|
||||
.filter(cargo -> java.util.Objects.equals(cargo.cargoId(), label.getInboundDetailId()))
|
||||
.findFirst().ifPresent(cargo -> repository.initializeLegacySku(tenantId, inboundId,
|
||||
label.getInboundDetailId(), cargo.skuNo())));
|
||||
String fingerprint = fingerprint(cargoes);
|
||||
if (!existing.isEmpty()) {
|
||||
String existingFingerprint = consistent.get(0).getCargoFingerprint();
|
||||
@@ -202,6 +210,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
label.setInboundDetailId(cargo.cargoId());
|
||||
label.setCargoItemNo(cargo.itemNo());
|
||||
label.setCargoName(cargo.cargoName());
|
||||
label.setSkuCode(cargo.skuNo());
|
||||
label.setDetailPackageCount(cargo.packageCount());
|
||||
label.setInboundCode(inbound.getInboundCode());
|
||||
label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index);
|
||||
@@ -256,7 +265,8 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
|
||||
private String fingerprint(List<OrderCargoClient.CargoLabelBinding> cargoes) {
|
||||
String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()),
|
||||
cargo.cargoName() == null ? "" : cargo.cargoName().trim(), String.valueOf(cargo.packageCount()),
|
||||
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
|
||||
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
|
||||
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
try {
|
||||
|
||||
+62
-1
@@ -53,13 +53,33 @@ public class LoadingCompletedOutboxService {
|
||||
validateContainerIdentity(rows);
|
||||
validateCustomerSnapshots(rows);
|
||||
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);
|
||||
@@ -213,9 +233,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()));
|
||||
@@ -266,6 +290,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);
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 将海外暂扣事实按来源入仓单绑定回写到国内头程入仓单。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OverseasInboundOrderDetainedConsumer {
|
||||
private final InboundOrderMapper inboundOrders;
|
||||
private final InboundHoldRecordMapper holdRecords;
|
||||
private final ContainerEventInboxMapper inbox;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Transactional
|
||||
public InboundOrderEntity consume(EventEnvelope event) {
|
||||
validateEnvelope(event);
|
||||
OverseasInboundOrderDetainedChangedV1 payload = payload(event);
|
||||
long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效");
|
||||
long tenantId = parsePositive(event.tenantId(), "客户 ID 无效");
|
||||
if (!payload.sourceInboundId().equals(event.aggregateId())) {
|
||||
throw new IllegalArgumentException("事件 aggregateId 与来源入仓单 ID 不一致");
|
||||
}
|
||||
if (payload.changedAt() == null || payload.inboundCode() == null || payload.inboundCode().isBlank()
|
||||
|| payload.reason() == null || payload.reason().isBlank()) {
|
||||
throw new IllegalArgumentException("暂扣事件缺少入仓码、原因或变更时间");
|
||||
}
|
||||
|
||||
ContainerEventInboxEntity existing = inbox.selectById(event.eventId());
|
||||
if (existing != null) {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
|
||||
}
|
||||
return requiredInbound(inboundId, tenantId, payload.inboundCode());
|
||||
}
|
||||
ContainerEventInboxEntity inboxRow = inbox(event);
|
||||
try {
|
||||
inbox.insert(inboxRow);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
|
||||
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
|
||||
return requiredInbound(inboundId, tenantId, payload.inboundCode());
|
||||
}
|
||||
|
||||
InboundOrderEntity inbound = requiredInbound(inboundId, tenantId, payload.inboundCode());
|
||||
if (inbound.getLastOverseasHoldVersion() == null
|
||||
|| event.aggregateVersion() > inbound.getLastOverseasHoldVersion()) {
|
||||
int updated = inboundOrders.applyOverseasHold(tenantId, inboundId, payload.detained(),
|
||||
payload.detained() ? payload.reason() : null, event.aggregateVersion(),
|
||||
parseOperatorId(payload.operatorId()));
|
||||
if (updated == 1) insertHistory(event, payload, tenantId, inboundId);
|
||||
}
|
||||
inboxRow.setStatus("PROCESSED");
|
||||
inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
inbox.updateById(inboxRow);
|
||||
return requiredInbound(inboundId, tenantId, payload.inboundCode());
|
||||
}
|
||||
|
||||
private void validateEnvelope(EventEnvelope event) {
|
||||
event.requireValid();
|
||||
if (!EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType())
|
||||
|| event.eventVersion() != 1 || event.sourceRegion() != RegionCode.OVERSEAS
|
||||
|| event.targetRegion() != RegionCode.CN || !"INBOUND_ORDER".equals(event.aggregateType())) {
|
||||
throw new IllegalArgumentException("不支持的海外入仓单暂扣事件");
|
||||
}
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) {
|
||||
throw new IllegalArgumentException("payloadHash 与事件负载不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private OverseasInboundOrderDetainedChangedV1 payload(EventEnvelope event) {
|
||||
try { return objectMapper.treeToValue(event.payload(), OverseasInboundOrderDetainedChangedV1.class); }
|
||||
catch (Exception exception) { throw new IllegalArgumentException("海外入仓单暂扣事件负载无效", exception); }
|
||||
}
|
||||
|
||||
private InboundOrderEntity requiredInbound(long inboundId, long tenantId, String inboundCode) {
|
||||
InboundOrderEntity value = inboundOrders.selectActiveByIdForUpdate(inboundId);
|
||||
if (value == null) throw new IllegalArgumentException("来源头程入仓单不存在");
|
||||
if (!Objects.equals(value.getTenantId(), tenantId) || !Objects.equals(value.getInboundCode(), inboundCode)) {
|
||||
throw new IllegalArgumentException("事件与来源头程入仓单归属不一致");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private void insertHistory(EventEnvelope event, OverseasInboundOrderDetainedChangedV1 payload,
|
||||
long tenantId, long inboundId) {
|
||||
InboundHoldRecordEntity row = new InboundHoldRecordEntity();
|
||||
row.setId(IdWorker.getId()); row.setTenantId(tenantId); row.setInboundId(inboundId);
|
||||
row.setHoldAction(payload.detained() ? "HOLD" : "RELEASE"); row.setReason(payload.reason());
|
||||
row.setOperatorId(parseOperatorId(payload.operatorId())); row.setOperatorName(payload.operatorName());
|
||||
row.setOperatedAt(LocalDateTime.ofInstant(payload.changedAt(), ZoneOffset.UTC));
|
||||
holdRecords.insert(row);
|
||||
}
|
||||
|
||||
private ContainerEventInboxEntity inbox(EventEnvelope event) {
|
||||
ContainerEventInboxEntity row = new ContainerEventInboxEntity();
|
||||
row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion());
|
||||
row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion());
|
||||
row.setPayloadHash(event.payloadHash().toLowerCase(Locale.ROOT)); row.setPayloadJson(event.payload().toString());
|
||||
row.setStatus("RECEIVED"); row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
return row;
|
||||
}
|
||||
|
||||
private long parsePositive(String value, String message) {
|
||||
try { long parsed = Long.parseLong(value); if (parsed > 0) return parsed; }
|
||||
catch (RuntimeException ignored) { }
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
|
||||
private long parseOperatorId(String value) {
|
||||
try { return value == null ? 0L : Long.parseLong(value); }
|
||||
catch (NumberFormatException ignored) { return 0L; }
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ DELIMITER ;
|
||||
CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''内部兼容批次字段,固定为1且不进入箱码'' AFTER carton_index');
|
||||
CALL phase2_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version');
|
||||
CALL phase2_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status');
|
||||
CALL phase2_carton_label_add_column('sku_code', 'VARCHAR(128) NULL COMMENT ''箱唛绑定的SKU快照;二维码内容不变'' AFTER cargo_name');
|
||||
CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint');
|
||||
DROP PROCEDURE phase2_carton_label_add_column;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
|
||||
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
|
||||
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
|
||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
|
||||
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
|
||||
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
|
||||
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
|
||||
last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖',
|
||||
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
|
||||
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg',
|
||||
chargeable_weight_kg DECIMAL(18,3) NULL COMMENT '当前费用快照的计费重量kg',
|
||||
@@ -196,6 +197,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
|
||||
cargo_item_no INT UNSIGNED NOT NULL COMMENT '订单货物明细序号快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
sku_code VARCHAR(128) NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变',
|
||||
detail_package_count INT UNSIGNED NOT NULL COMMENT '该订单货物明细箱数快照',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
|
||||
box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标',
|
||||
|
||||
@@ -104,8 +104,8 @@ CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||
event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本',
|
||||
aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID',
|
||||
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本',
|
||||
aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID',
|
||||
aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本',
|
||||
payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要',
|
||||
payload_json JSON NOT NULL COMMENT '原始事件负载',
|
||||
status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED',
|
||||
@@ -113,7 +113,7 @@ CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
|
||||
processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)',
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_container_event_aggregate (aggregate_id, aggregate_version)
|
||||
) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox';
|
||||
) ENGINE=InnoDB COMMENT='海外到国内业务事件消费Inbox';
|
||||
|
||||
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
|
||||
DELIMITER $$
|
||||
@@ -673,6 +673,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()
|
||||
|
||||
@@ -170,11 +170,104 @@ paths:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
responses: {'200': {description: 作废后柜级证据列表}}
|
||||
/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
@@ -290,6 +290,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);
|
||||
@@ -347,6 +371,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;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.OverseasInboundOrderDetainedChangedV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerEventInboxEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerEventInboxMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
|
||||
import com.baiyue.firstleg.mapper.InboundOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OverseasInboundOrderDetainedConsumerTest {
|
||||
@Mock InboundOrderMapper inboundOrders;
|
||||
@Mock InboundHoldRecordMapper holdRecords;
|
||||
@Mock ContainerEventInboxMapper inbox;
|
||||
ObjectMapper objectMapper;
|
||||
OverseasInboundOrderDetainedConsumer consumer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
consumer = new OverseasInboundOrderDetainedConsumer(inboundOrders, holdRecords, inbox, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesHoldBySourceInboundIdAndWritesHistory() {
|
||||
InboundOrderEntity inbound = inbound(null);
|
||||
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound);
|
||||
when(inboundOrders.applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L)).thenReturn(1);
|
||||
|
||||
consumer.consume(event(7, true, "等待查验"));
|
||||
|
||||
verify(inboundOrders).applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L);
|
||||
ArgumentCaptor<InboundHoldRecordEntity> history = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
|
||||
verify(holdRecords).insert(history.capture());
|
||||
assertThat(history.getValue().getInboundId()).isEqualTo(1001L);
|
||||
assertThat(history.getValue().getHoldAction()).isEqualTo("HOLD");
|
||||
verify(inbox).updateById(argThat((ContainerEventInboxEntity row) -> "PROCESSED".equals(row.getStatus())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleReleaseCannotOverwriteNewerHoldState() {
|
||||
when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound(9L));
|
||||
|
||||
consumer.consume(event(7, false, "解除"));
|
||||
|
||||
verify(inboundOrders, never()).applyOverseasHold(anyLong(), anyLong(), anyBoolean(), any(), anyLong(), anyLong());
|
||||
verifyNoInteractions(holdRecords);
|
||||
verify(inbox).updateById(argThat((ContainerEventInboxEntity row) -> "PROCESSED".equals(row.getStatus())));
|
||||
}
|
||||
|
||||
private InboundOrderEntity inbound(Long sourceVersion) {
|
||||
InboundOrderEntity value = new InboundOrderEntity();
|
||||
value.setId(1001L); value.setTenantId(88L); value.setInboundCode("IN-1001");
|
||||
value.setLastOverseasHoldVersion(sourceVersion);
|
||||
return value;
|
||||
}
|
||||
|
||||
private EventEnvelope event(long version, boolean detained, String reason) {
|
||||
var payload = new OverseasInboundOrderDetainedChangedV1("1001", "IN-1001", "9001",
|
||||
"LM-9001", detained, reason, "66", "海外操作员", Instant.parse("2026-08-31T06:00:00Z"));
|
||||
var node = objectMapper.valueToTree(payload);
|
||||
return new EventEnvelope(String.format("00000000-0000-4000-8000-%012d", version),
|
||||
EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, 1, payload.changedAt(),
|
||||
RegionCode.OVERSEAS, RegionCode.CN, "88", "INBOUND_ORDER", "1001", version, 1L,
|
||||
"00000000-0000-4000-8000-000000000099", "hold-1001-" + version, null, node,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user