merge: integrate TEST updates and verify appointment release
This commit is contained in:
+3
-1
@@ -1158,6 +1158,7 @@ public class DeliveryTransferService {
|
||||
required(request == null ? null : request.reason(), "取消原因不能为空");
|
||||
OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo));
|
||||
if (observed == null) throw notFound("Outbound order not found");
|
||||
if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消");
|
||||
// Match dispatch picking and relabel confirmation lock order before locking outbound.
|
||||
DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null
|
||||
: dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId());
|
||||
@@ -1425,6 +1426,7 @@ public class DeliveryTransferService {
|
||||
new LambdaQueryWrapper<OutboundOrder>().eq(StringUtils.hasText(customerId), OutboundOrder::getCustomerId, customerId)
|
||||
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
|
||||
.like(StringUtils.hasText(query.lxCode()), OutboundOrder::getLxCodes, query.lxCode())
|
||||
.ne(StringUtils.hasText(query.lxCode()), OutboundOrder::getOutboundType, "DISCARD_OUTBOUND")
|
||||
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
|
||||
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
|
||||
.eq(StringUtils.hasText(query.outboundType()), OutboundOrder::getOutboundType, query.outboundType())
|
||||
@@ -1610,7 +1612,7 @@ public class DeliveryTransferService {
|
||||
d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(),
|
||||
d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed);
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); }
|
||||
private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
|
||||
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
|
||||
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
|
||||
|
||||
-7
@@ -77,13 +77,6 @@ public class CargoDiscardController {
|
||||
return ApiResponse.success(service.cancel(scope(user, customerId), discardNo, operator(user), true));
|
||||
}
|
||||
|
||||
@PostMapping("/{discardNo}/complete")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
|
||||
requireApprovalPermission(user);
|
||||
return ApiResponse.success(service.complete(scope(user, customerId), discardNo, operator(user)));
|
||||
}
|
||||
|
||||
private String scope(AuthenticatedUser user, String requested) {
|
||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||
|
||||
+6
@@ -46,6 +46,12 @@ public class MerchantCargoDiscardController {
|
||||
org.springframework.http.ContentDisposition.inline().filename(file.fileName(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).body(file.content());
|
||||
}
|
||||
@PostMapping("/{discardNo}/confirm-completion")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CONFIRM + "')")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String discardNo) {
|
||||
return ApiResponse.success(service.confirmCompletion(customer(user), discardNo, operator(user)));
|
||||
}
|
||||
@PostMapping("/{discardNo}/cancel")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
|
||||
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+8
-1
@@ -20,7 +20,14 @@ public final class CargoDiscardRequests {
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record Item(Long orderId, Long returnCartonId, @NotBlank @Size(max = 128) String skuCode,
|
||||
@Positive int discardPackages) {}
|
||||
@Positive int discardPackages, Long inventoryPositionId, @Size(max = 5000) List<@NotNull @Positive Long> cartonIds) {
|
||||
public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages, Long inventoryPositionId) {
|
||||
this(orderId, returnCartonId, skuCode, discardPackages, inventoryPositionId, null);
|
||||
}
|
||||
public Item(Long orderId, Long returnCartonId, String skuCode, int discardPackages) {
|
||||
this(orderId, returnCartonId, skuCode, discardPackages, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
|
||||
|
||||
|
||||
+24
-3
@@ -11,16 +11,37 @@ public final class CargoDiscardResponses {
|
||||
String inboundCode, String inboundOrderNo,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
LocalDateTime inboundCreatedAt, long availablePackages,
|
||||
List<CandidateSku> skus) {}
|
||||
List<CandidateSku> skus, Long inventoryPositionId, String palletNo, List<Box> cartons, boolean cartonSelectionSupported, String cartonSelectionBlockReason) {
|
||||
public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark,
|
||||
String inboundCode, String inboundOrderNo, String overseasWarehouseId,
|
||||
String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages,
|
||||
List<CandidateSku> skus, Long inventoryPositionId, String palletNo) {
|
||||
this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo,
|
||||
overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus,
|
||||
inventoryPositionId, palletNo, List.of(), false, "当前库存缺少可核对的箱级记录,可按全部库存废弃");
|
||||
}
|
||||
public Candidate(Long orderId, String sourceType, Long returnCartonId, String cartonMark,
|
||||
String inboundCode, String inboundOrderNo, String overseasWarehouseId,
|
||||
String overseasWarehouseName, LocalDateTime inboundCreatedAt, long availablePackages,
|
||||
List<CandidateSku> skus) {
|
||||
this(orderId, sourceType, returnCartonId, cartonMark, inboundCode, inboundOrderNo,
|
||||
overseasWarehouseId, overseasWarehouseName, inboundCreatedAt, availablePackages, skus, null, null);
|
||||
}
|
||||
}
|
||||
public record Box(Long cartonId, String cartonMark, String palletNo, List<CandidateSku> skus) {}
|
||||
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
|
||||
|
||||
public record Summary(Long id, String customerId, String discardNo, String inboundCodes,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
int totalPackages, BigDecimal totalFee, String currency, String status,
|
||||
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
|
||||
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {}
|
||||
|
||||
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
||||
String productName, int availablePackagesSnapshot, int discardPackages) {}
|
||||
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) {
|
||||
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) {
|
||||
this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
public record History(String fromStatus, String toStatus, String operatorId,
|
||||
String remark, LocalDateTime createdAt) {}
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ public class CargoDiscardApplication {
|
||||
private String createdBy;
|
||||
private String reviewedBy;
|
||||
private String reviewRemark;
|
||||
private LocalDateTime customerConfirmedAt;
|
||||
private LocalDateTime reviewedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public class CargoDiscardCarton {
|
||||
private Long itemId;
|
||||
private String customerId;
|
||||
private Long cartonId;
|
||||
private String sourceType;
|
||||
private String cartonMark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ public class CargoDiscardItem {
|
||||
private Long orderId;
|
||||
private String sourceType;
|
||||
private Long returnCartonId;
|
||||
private Long inventoryPositionId;
|
||||
private String cartonMarksJson;
|
||||
private Boolean cartonScanRequired;
|
||||
private String inboundCode;
|
||||
private Long profileId;
|
||||
private Long skuInventoryId;
|
||||
|
||||
+60
@@ -12,6 +12,66 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CargoDiscardInventoryMapper {
|
||||
|
||||
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
|
||||
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||
|
||||
@Select("""
|
||||
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type
|
||||
FROM ll_inbound_plan_carton c
|
||||
JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id
|
||||
JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id
|
||||
WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL
|
||||
AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'')
|
||||
UNION ALL
|
||||
SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type
|
||||
FROM ll_inventory_position p
|
||||
JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id
|
||||
JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id
|
||||
AND t.transfer_no=p.batch_no AND t.operating_warehouse_id=p.overseas_warehouse_id
|
||||
JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND c.status='EXPECTED'
|
||||
JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id
|
||||
WHERE p.customer_id=#{customerId} AND p.id=#{positionId}
|
||||
AND (SELECT COUNT(*) FROM ll_inventory_position current_position
|
||||
WHERE current_position.customer_id=p.customer_id AND current_position.balance_id=p.balance_id
|
||||
AND current_position.batch_no=p.batch_no AND current_position.overseas_warehouse_id=p.overseas_warehouse_id
|
||||
AND current_position.available_packages+current_position.hold_packages+current_position.allocated_packages+current_position.picked_packages>0)=1
|
||||
ORDER BY id
|
||||
""")
|
||||
List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||
|
||||
@Update("UPDATE ll_warehouse_transfer_carton SET status=#{toStatus},updated_at=NOW(3) " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
|
||||
int changeTransferCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
|
||||
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
|
||||
|
||||
@Update("UPDATE ll_inbound_plan_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
|
||||
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
|
||||
int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
|
||||
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
|
||||
|
||||
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id")
|
||||
List<DropshipCarton> availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||
|
||||
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
|
||||
"AND c.status='AVAILABLE' AND c.id IN <foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"AND EXISTS(SELECT 1 FROM ll_dropship_carton_sku cs WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) ORDER BY c.profile_id,c.id FOR UPDATE</script>")
|
||||
List<DropshipCarton> lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||
@Param("skuCode") String skuCode, @Param("cartonIds") List<Long> cartonIds);
|
||||
|
||||
@Select("<script>SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id " +
|
||||
"WHERE p.customer_id=#{customerId} AND p.available_packages>0 AND b.available_packages>0 " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_warehouse_transfer_inventory_allocation a WHERE a.customer_id=p.customer_id AND a.inventory_position_id=p.id AND a.status='RESERVED') " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM ll_dropship_order_profile d WHERE d.customer_id=p.customer_id AND d.inventory_position_id=p.id) " +
|
||||
"<if test='keyword != null and keyword != \"\"'>AND b.inbound_code LIKE CONCAT('%',#{keyword},'%')</if> ORDER BY p.id</script>")
|
||||
List<com.baiyue.lastleg.inventory.entity.InventoryPosition> genericPositions(@Param("customerId") String customerId, @Param("keyword") String keyword);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId}")
|
||||
long isDropshipPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||
|
||||
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
|
||||
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
|
||||
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
|
||||
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
|
||||
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
|
||||
"COUNT(c.id) available_packages FROM ll_order o " +
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.lastleg.discard.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** A real carton whose current stock position can be established from warehouse facts. */
|
||||
@Data
|
||||
public class CargoDiscardStockCartonRow {
|
||||
private Long id;
|
||||
private String cartonMark;
|
||||
private String skuCode;
|
||||
private String sourceType;
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ public class PdaCargoDiscardController {
|
||||
}
|
||||
@PostMapping("/resolve") public ApiResponse<PdaCargoDiscardDtos.PalletTask> resolve(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody PdaCargoDiscardDtos.Scan request) {
|
||||
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo()));
|
||||
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request.palletNo(), request.discardNo()));
|
||||
}
|
||||
@PostMapping(value = "/{discardNo}/pallets/{palletNo}/photos/files", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<com.baiyue.lastleg.discard.dto.CargoDiscardResponses.Photo> uploadPhoto(
|
||||
|
||||
+9
-3
@@ -10,12 +10,18 @@ import java.util.List;
|
||||
|
||||
public final class PdaCargoDiscardDtos {
|
||||
private PdaCargoDiscardDtos() { }
|
||||
public record Scan(@NotBlank @Size(max = 128) String palletNo) { }
|
||||
public record Scan(@NotBlank @Size(max = 128) String palletNo, @Size(max = 128) String discardNo) {
|
||||
public Scan(String palletNo) { this(palletNo, null); }
|
||||
}
|
||||
public record ActualLine(@NotNull Long itemId, @PositiveOrZero int actualPackages) { }
|
||||
public record Confirm(@NotEmpty List<@Valid ActualLine> lines,
|
||||
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds) { }
|
||||
@NotEmpty @Size(max = 20) List<@NotNull @jakarta.validation.constraints.Positive Long> photoFileIds,
|
||||
List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) {
|
||||
public Confirm(List<ActualLine> lines, List<Long> photoFileIds) { this(lines, photoFileIds, null, false); }
|
||||
}
|
||||
public record Sku(Long itemId, String skuCode, String productName, int plannedPackages,
|
||||
Integer actualPackages, String status) { }
|
||||
public record PalletTask(String discardNo, String customerId, String overseasWarehouseId,
|
||||
String palletNo, Long profileId, String status, List<Sku> skus) { }
|
||||
String palletNo, Long profileId, String status, List<Sku> skus, String outboundNo,
|
||||
String scanMode, List<String> cartonMarks) { }
|
||||
}
|
||||
|
||||
+60
-22
@@ -25,12 +25,14 @@ import java.util.stream.Collectors;
|
||||
public class PdaCargoDiscardService {
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService;
|
||||
@Resource private CargoDiscardApplicationMapper applications;
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper cartons;
|
||||
@Resource private DropshipOrderProfileMapper profiles;
|
||||
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
|
||||
@Resource private CargoDiscardService discardService;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService;
|
||||
|
||||
public List<PdaCargoDiscardDtos.PalletTask> tasks(PdaWarehouseIdentity identity) {
|
||||
return applications.selectList(new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||
@@ -41,7 +43,14 @@ public class PdaCargoDiscardService {
|
||||
}
|
||||
|
||||
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) {
|
||||
return tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim())).findFirst()
|
||||
return resolve(identity, palletNo, null);
|
||||
}
|
||||
|
||||
public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) {
|
||||
var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim()))
|
||||
.filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList();
|
||||
if (matches.size() > 1) throw conflict("该托盘有多个废弃任务,请先选择废弃单");
|
||||
return matches.stream().findFirst()
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务"));
|
||||
}
|
||||
|
||||
@@ -62,12 +71,19 @@ public class PdaCargoDiscardService {
|
||||
if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) {
|
||||
throw conflict("必须提交该托盘全部 SKU 的实际废弃数量");
|
||||
}
|
||||
if (task.skus().stream().anyMatch(line -> !Objects.equals(actuals.get(line.itemId()), line.plannedPackages())))
|
||||
throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
|
||||
if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) {
|
||||
var scanned = request.scannedCartonMarks() == null ? List.<String>of() : request.scannedCartonMarks();
|
||||
if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks())))
|
||||
throw conflict("必须扫描全部箱唛,或选择一键全部废弃");
|
||||
}
|
||||
List<CargoDiscardItem> palletItems = new ArrayList<>();
|
||||
for (PdaCargoDiscardDtos.Sku line : task.skus()) {
|
||||
CargoDiscardItem item = items.selectById(line.itemId());
|
||||
if (item == null || !application.getCustomerId().equals(item.getCustomerId())
|
||||
|| !Objects.equals(application.getId(), item.getApplicationId())
|
||||
|| !Objects.equals(task.profileId(), isReturnItem(item) ? item.getReturnCartonId() : item.getProfileId()))
|
||||
|| !physicalCode(application, item).equals(task.palletNo()))
|
||||
throw conflict("废弃明细不属于当前申请或托盘");
|
||||
if ("COMPLETED".equals(item.getPdaStatus())
|
||||
&& !Objects.equals(item.getActualDiscardPackages(), actuals.get(item.getId())))
|
||||
@@ -106,8 +122,9 @@ public class PdaCargoDiscardService {
|
||||
application.setTotalPackages(actualTotal);
|
||||
application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal)));
|
||||
discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED,
|
||||
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待仓库确认");
|
||||
CargoDiscardService.PDA_COMPLETED, identity.operatorKey(), "全部托盘 PDA 废弃作业完成,待客户确认");
|
||||
}
|
||||
outboundService.sync(application);
|
||||
return palletTasks(application).stream().filter(value -> value.palletNo().equals(palletNo.trim()))
|
||||
.findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"));
|
||||
}
|
||||
@@ -142,27 +159,48 @@ public class PdaCargoDiscardService {
|
||||
return application;
|
||||
}
|
||||
|
||||
private static final String LOOSE = "散箱";
|
||||
private List<PdaCargoDiscardDtos.PalletTask> palletTasks(CargoDiscardApplication application) {
|
||||
return items.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
.collect(Collectors.groupingBy(this::physicalGroupKey, LinkedHashMap::new, Collectors.toList()))
|
||||
.entrySet().stream().map(entry -> {
|
||||
CargoDiscardItem first = entry.getValue().get(0);
|
||||
boolean returnSource = isReturnItem(first);
|
||||
DropshipOrderProfile profile = returnSource ? null : profiles.selectById(first.getProfileId());
|
||||
ReturnInventoryCarton returnCarton = returnSource ? returnCartons.selectById(first.getReturnCartonId()) : null;
|
||||
if (!returnSource && (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))) throw conflict("废弃明细关联托盘不存在");
|
||||
if (returnSource && (returnCarton == null || !application.getCustomerId().equals(returnCarton.getCustomerId()))) throw conflict("废弃明细关联退货箱不存在");
|
||||
List<PdaCargoDiscardDtos.Sku> skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(
|
||||
i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(),
|
||||
i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
|
||||
String status = skus.stream().allMatch(s -> "COMPLETED".equals(s.status())) ? "COMPLETED" : "PENDING";
|
||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(),
|
||||
application.getOverseasWarehouseId(), returnSource ? firstNonBlank(returnCarton.getPalletCode(), returnCarton.getCartonMark()) : profile.getPalletNo(),
|
||||
returnSource ? returnCarton.getId() : profile.getId(), status, skus);
|
||||
}).toList();
|
||||
var allItems = items.listApplication(application.getCustomerId(), application.getId());
|
||||
var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList()));
|
||||
return grouped.entrySet().stream().map(entry -> {
|
||||
CargoDiscardItem first = entry.getValue().get(0);
|
||||
var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(),
|
||||
i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList();
|
||||
String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING";
|
||||
var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList();
|
||||
boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired()));
|
||||
if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛");
|
||||
return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(),
|
||||
entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(),
|
||||
status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks);
|
||||
}).toList();
|
||||
}
|
||||
private String physicalGroupKey(CargoDiscardItem item) {
|
||||
return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId();
|
||||
private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) {
|
||||
String code;
|
||||
if ("INVENTORY".equals(item.getSourceType())) {
|
||||
var position = stockService.position(application.getCustomerId(), item.getInventoryPositionId());
|
||||
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库");
|
||||
code = position.getPalletNo();
|
||||
} else if (isReturnItem(item)) {
|
||||
var carton = returnCartons.selectById(item.getReturnCartonId());
|
||||
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在");
|
||||
code = carton.getPalletCode();
|
||||
} else {
|
||||
var profile = profiles.selectById(item.getProfileId());
|
||||
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在");
|
||||
code = profile.getPalletNo();
|
||||
}
|
||||
return code == null || code.isBlank() ? LOOSE : code.trim();
|
||||
}
|
||||
private List<String> cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) {
|
||||
if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item);
|
||||
if (isReturnItem(item)) {
|
||||
var carton = returnCartons.selectById(item.getReturnCartonId());
|
||||
return carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark());
|
||||
}
|
||||
return cartons.listApplication(application.getCustomerId(), application.getId()).stream()
|
||||
.filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList();
|
||||
}
|
||||
private boolean isReturnItem(CargoDiscardItem item) {
|
||||
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
|
||||
|
||||
+6
@@ -34,6 +34,7 @@ import java.util.Map;
|
||||
@Service
|
||||
public class CargoDiscardInventoryAdjustmentService {
|
||||
@Resource private CargoDiscardInventoryMapper inventory;
|
||||
@Resource private CargoDiscardStockService stockService;
|
||||
@Resource private DropshipCartonLedgerMapper cartonLedgers;
|
||||
@Resource private DropshipCartonSkuMapper cartonSkus;
|
||||
@Resource private DropshipSkuInventoryMapper skuInventories;
|
||||
@@ -47,6 +48,11 @@ public class CargoDiscardInventoryAdjustmentService {
|
||||
public AdjustmentResult discardPalletItem(String customerId, CargoDiscardApplication application,
|
||||
CargoDiscardItem item, List<CargoDiscardCarton> links,
|
||||
int actualPackages, String operatorId, LocalDateTime occurredAt) {
|
||||
if (actualPackages != item.getDiscardPackages()) throw conflict("必须完成本次废弃单所选全部箱子,不能漏出");
|
||||
if ("INVENTORY".equals(item.getSourceType())) {
|
||||
stockService.discard(customerId, operatorId, application, item, actualPackages);
|
||||
return new AdjustmentResult(item.getDiscardPackages(), actualPackages, 0);
|
||||
}
|
||||
if (isReturnItem(item)) return discardReturnItem(customerId, item, actualPackages);
|
||||
if (actualPackages < 0 || actualPackages > links.size()) throw conflict("实际废弃数量超出该托盘申请数量");
|
||||
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.baiyue.lastleg.discard.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
|
||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Outbound projection; inventory writes belong exclusively to the discard workflow. */
|
||||
@Service
|
||||
public class CargoDiscardOutboundService {
|
||||
public static final String TYPE = "DISCARD_OUTBOUND";
|
||||
@Resource private OutboundOrderMapper outbounds;
|
||||
@Resource private OutboundPalletItemMapper pallets;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper cartons;
|
||||
@Resource private DropshipOrderProfileMapper profiles;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private CargoDiscardStockService stockService;
|
||||
|
||||
private String key(CargoDiscardApplication application) { return "CARGO_DISCARD:" + application.getId(); }
|
||||
|
||||
public String outboundNo(CargoDiscardApplication application) {
|
||||
var outbound = outbounds.findByIdempotency(application.getCustomerId(), key(application));
|
||||
return outbound == null ? null : outbound.getOutboundNo();
|
||||
}
|
||||
|
||||
// The caller holds the application lock and owns the approval transaction.
|
||||
public void create(CargoDiscardApplication application, String operatorId) {
|
||||
if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return;
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
if (lines.isEmpty()) throw conflict("废弃单没有可出库明细");
|
||||
var links = cartons.listApplication(application.getCustomerId(), application.getId());
|
||||
LocalDateTime at = LocalDateTime.now();
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId());
|
||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE);
|
||||
outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
|
||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages());
|
||||
outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application));
|
||||
outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO);
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at);
|
||||
var snapshots = new java.util.ArrayList<OutboundPalletItem>();
|
||||
var palletCodes = new java.util.HashSet<String>();
|
||||
for (var line : lines) {
|
||||
boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType());
|
||||
String palletNo;
|
||||
String cartonMarks;
|
||||
if ("INVENTORY".equals(line.getSourceType())) {
|
||||
var position = stockService.position(application.getCustomerId(), line.getInventoryPositionId());
|
||||
if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存仓库不匹配");
|
||||
palletNo = position.getPalletNo();
|
||||
cartonMarks = String.join(",", stockService.cartonMarks(line));
|
||||
} else if (returned) {
|
||||
var carton = returnCartons.selectById(line.getReturnCartonId());
|
||||
if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())
|
||||
|| !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()))
|
||||
throw conflict("废弃明细关联退货箱不存在或仓库不匹配");
|
||||
palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode();
|
||||
cartonMarks = carton.getCartonMark();
|
||||
} else {
|
||||
var profile = profiles.selectById(line.getProfileId());
|
||||
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId()))
|
||||
throw conflict("废弃明细关联托盘不存在");
|
||||
palletNo = profile.getPalletNo();
|
||||
cartonMarks = links.stream().filter(c -> Objects.equals(c.getItemId(), line.getId()))
|
||||
.map(c -> c.getCartonMark()).filter(Objects::nonNull).collect(java.util.stream.Collectors.joining(","));
|
||||
}
|
||||
if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo);
|
||||
OutboundPalletItem snapshot = new OutboundPalletItem();
|
||||
// Stable one-to-one projection key in a separate table, never used to deduct stock.
|
||||
snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId());
|
||||
snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode());
|
||||
snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks);
|
||||
snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK");
|
||||
if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages());
|
||||
snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at);
|
||||
snapshots.add(snapshot);
|
||||
}
|
||||
outbound.setPalletCount(palletCodes.size()); outbounds.insert(outbound);
|
||||
snapshots.forEach(pallets::insert);
|
||||
}
|
||||
|
||||
public void sync(CargoDiscardApplication application) {
|
||||
OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application));
|
||||
if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果");
|
||||
var lines = items.listApplication(application.getCustomerId(), application.getId());
|
||||
for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) {
|
||||
var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
|
||||
.orElseThrow(() -> conflict("废弃出库明细关联失效"));
|
||||
if ("COMPLETED".equals(line.getPdaStatus())) {
|
||||
snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0));
|
||||
snapshot.setStatus("PICKED");
|
||||
}
|
||||
if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED");
|
||||
pallets.updateById(snapshot);
|
||||
}
|
||||
String status = switch (application.getStatus()) {
|
||||
case CargoDiscardService.PDA_COMPLETED, CargoDiscardService.COMPLETED -> "OUTBOUNDED";
|
||||
case CargoDiscardService.CANCELLED -> "CANCELLED";
|
||||
default -> lines.stream().anyMatch(i -> "COMPLETED".equals(i.getPdaStatus())) ? "PICKING" : "WAIT_PICK";
|
||||
};
|
||||
outbound.setStatus(status);
|
||||
outbound.setLxCodes(null);
|
||||
if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages());
|
||||
outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound);
|
||||
}
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+119
-22
@@ -61,6 +61,10 @@ public class CargoDiscardService {
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED, CANCELLED, PDA_COMPLETED, COMPLETED);
|
||||
|
||||
@Resource private CargoDiscardPhotoService photoService;
|
||||
@Resource private CargoDiscardOutboundService outboundService;
|
||||
@Resource private CargoDiscardStockService stockService;
|
||||
@Resource private com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper cartonSkus;
|
||||
@Resource private com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper profiles;
|
||||
@Resource private CargoDiscardApplicationMapper applications;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper discardCartons;
|
||||
@@ -72,6 +76,8 @@ public class CargoDiscardService {
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
||||
private BigDecimal feePerPackage = new BigDecimal("5.00");
|
||||
|
||||
@@ -85,6 +91,7 @@ public class CargoDiscardService {
|
||||
List<CargoDiscardResponses.Candidate> result = grouped.values().stream()
|
||||
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
|
||||
.collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new));
|
||||
result.addAll(stockService.candidates(customerId, trim(inboundCode)));
|
||||
String keyword = trim(inboundCode);
|
||||
if (returnCartons == null || returnCartonItems == null) return result;
|
||||
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) {
|
||||
@@ -100,7 +107,35 @@ public class CargoDiscardService {
|
||||
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
|
||||
null, available, skus));
|
||||
}
|
||||
return result;
|
||||
Map<String, String> names = result.stream().anyMatch(c -> !StringUtils.hasText(c.overseasWarehouseName()))
|
||||
? warehouseNames() : Map.of();
|
||||
return result.stream().map(candidate -> withBoxes(customerId, new CargoDiscardResponses.Candidate(
|
||||
candidate.orderId(), candidate.sourceType(), candidate.returnCartonId(), candidate.cartonMark(),
|
||||
candidate.inboundCode(), candidate.inboundOrderNo(), candidate.overseasWarehouseId(),
|
||||
StringUtils.hasText(candidate.overseasWarehouseName()) ? candidate.overseasWarehouseName() : names.get(candidate.overseasWarehouseId()),
|
||||
candidate.inboundCreatedAt(), candidate.availablePackages(), candidate.skus(), candidate.inventoryPositionId(),
|
||||
candidate.palletNo(), candidate.cartons(), candidate.cartonSelectionSupported(), candidate.cartonSelectionBlockReason()))).toList();
|
||||
}
|
||||
|
||||
private CargoDiscardResponses.Candidate withBoxes(String customer, CargoDiscardResponses.Candidate candidate) {
|
||||
if ("INVENTORY".equals(candidate.sourceType())) return candidate;
|
||||
List<CargoDiscardResponses.Box> boxes;
|
||||
if ("RETURN_CARTON".equals(candidate.sourceType())) {
|
||||
var carton = returnCartons.selectById(candidate.returnCartonId());
|
||||
boxes = List.of(new CargoDiscardResponses.Box(candidate.returnCartonId(), candidate.cartonMark(), carton == null ? null : carton.getPalletCode(), candidate.skus()));
|
||||
} else {
|
||||
boxes = inventory.availableOrderCartons(customer, candidate.orderId()).stream().map(carton -> {
|
||||
var profile = profiles.selectById(carton.getProfileId());
|
||||
var skus = cartonSkus.listCarton(customer, carton.getId()).stream().map(sku -> new CargoDiscardResponses.CandidateSku(sku.getSkuCode(), sku.getProductName(), 1)).toList();
|
||||
if (skus.isEmpty()) skus = List.of(new CargoDiscardResponses.CandidateSku(carton.getSkuCode(), null, 1));
|
||||
return new CargoDiscardResponses.Box(carton.getId(), carton.getCartonMark(), profile == null ? null : profile.getPalletNo(), skus);
|
||||
}).toList();
|
||||
}
|
||||
boolean supported = !boxes.isEmpty() && boxes.stream().allMatch(box -> box.cartonMark() != null && !box.cartonMark().isBlank());
|
||||
return new CargoDiscardResponses.Candidate(candidate.orderId(), candidate.sourceType(), candidate.returnCartonId(), candidate.cartonMark(),
|
||||
candidate.inboundCode(), candidate.inboundOrderNo(), candidate.overseasWarehouseId(), candidate.overseasWarehouseName(), candidate.inboundCreatedAt(),
|
||||
candidate.availablePackages(), candidate.skus(), candidate.inventoryPositionId(), candidate.palletNo(), boxes, supported,
|
||||
supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -116,6 +151,7 @@ public class CargoDiscardService {
|
||||
return detail(customerId, replay.getDiscardNo());
|
||||
}
|
||||
Set<String> requestedDimensions = new LinkedHashSet<>();
|
||||
Set<String> requestedBoxes = new LinkedHashSet<>();
|
||||
Map<Long, LastlegOrder> orderCache = new HashMap<>();
|
||||
Map<Long, ReturnInventoryCarton> returnCartonCache = new HashMap<>();
|
||||
String warehouseId = null;
|
||||
@@ -123,19 +159,29 @@ public class CargoDiscardService {
|
||||
int total = 0;
|
||||
for (CargoDiscardRequests.Item input : request.items()) {
|
||||
boolean returnSource = input.returnCartonId() != null;
|
||||
if (returnSource == (input.orderId() != null)) {
|
||||
if (input.cartonIds() != null) {
|
||||
if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致");
|
||||
String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP";
|
||||
for (Long cartonId : input.cartonIds()) if (!requestedBoxes.add(source + ":" + cartonId)) throw badRequest("同一个箱子不能重复废弃");
|
||||
}
|
||||
int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0);
|
||||
if (sources != 1) {
|
||||
throw badRequest("Each discard item must identify exactly one inventory source");
|
||||
}
|
||||
String dimension = (returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId())
|
||||
String dimension = (input.inventoryPositionId() != null ? "INVENTORY:" + input.inventoryPositionId() : returnSource ? "RETURN:" + input.returnCartonId() : "ORDER:" + input.orderId())
|
||||
+ ":" + input.skuCode().trim();
|
||||
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
|
||||
String itemWarehouseId;
|
||||
String itemWarehouseName;
|
||||
if (returnSource) {
|
||||
if (input.inventoryPositionId() != null) {
|
||||
var balance = stockService.validate(customerId, input);
|
||||
itemWarehouseId = balance.getOverseasWarehouseId();
|
||||
itemWarehouseName = balance.getOverseasWarehouseName();
|
||||
} else if (returnSource) {
|
||||
ReturnInventoryCarton carton = requireReturnCarton(customerId, input.returnCartonId());
|
||||
ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim());
|
||||
if (sku == null || value(sku.getQuantity()) < input.discardPackages()) {
|
||||
throw conflict("Insufficient return inventory for SKU " + input.skuCode().trim());
|
||||
if (sku == null || value(sku.getQuantity()) != input.discardPackages()) {
|
||||
throw conflict("目前仅支持全部废弃,退货 SKU 数量不匹配: " + input.skuCode().trim());
|
||||
}
|
||||
returnCartonCache.put(carton.getId(), carton);
|
||||
itemWarehouseId = carton.getOverseasWarehouseId();
|
||||
@@ -155,13 +201,23 @@ public class CargoDiscardService {
|
||||
total = Math.addExact(total, input.discardPackages());
|
||||
}
|
||||
|
||||
for (Long cartonId : returnCartonCache.keySet()) {
|
||||
Set<String> availableSkus = returnCartonItems.lockCarton(cartonId).stream()
|
||||
.filter(sku -> value(sku.getQuantity()) > 0).map(ReturnInventoryCartonItem::getSkuCode)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> requestedSkus = request.items().stream().filter(i -> cartonId.equals(i.returnCartonId()))
|
||||
.map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet());
|
||||
if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId);
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
||||
application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId()));
|
||||
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
|
||||
application.setInboundCodes(java.util.stream.Stream.concat(
|
||||
orderCache.values().stream().map(LastlegOrder::getInboundCode),
|
||||
java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode),
|
||||
request.items().stream().filter(i -> i.inventoryPositionId() != null).map(i -> stockService.validate(customerId, i).getInboundCode())),
|
||||
returnCartonCache.values().stream().map(ReturnInventoryCarton::getSourceReturnNo))
|
||||
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
|
||||
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
|
||||
@@ -176,7 +232,9 @@ public class CargoDiscardService {
|
||||
|
||||
Set<Long> reservedReturnCartons = new LinkedHashSet<>();
|
||||
for (CargoDiscardRequests.Item input : request.items()) {
|
||||
if (input.returnCartonId() != null) {
|
||||
if (input.inventoryPositionId() != null) {
|
||||
stockService.reserve(customerId, operatorId, application, input, now);
|
||||
} else if (input.returnCartonId() != null) {
|
||||
reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now,
|
||||
reservedReturnCartons);
|
||||
} else {
|
||||
@@ -187,6 +245,16 @@ public class CargoDiscardService {
|
||||
return detail(customerId, application.getDiscardNo());
|
||||
}
|
||||
|
||||
private List<String> selectedMarks(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
|
||||
if ("INVENTORY".equals(line.getSourceType())) return stockService.cartonMarks(line);
|
||||
if (isReturnItem(line)) {
|
||||
var carton = returnCartons.selectById(line.getReturnCartonId());
|
||||
return carton == null || carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark());
|
||||
}
|
||||
return discardCartons.listApplication(customer, application.getId()).stream().filter(link -> link.getItemId().equals(line.getId()))
|
||||
.map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList();
|
||||
}
|
||||
|
||||
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
|
||||
validateQuery(query);
|
||||
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||
@@ -200,7 +268,9 @@ public class CargoDiscardService {
|
||||
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
|
||||
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
|
||||
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList();
|
||||
Map<String, String> names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName()))
|
||||
? warehouseNames() : Map.of();
|
||||
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(a -> summary(a, names)).toList();
|
||||
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
@@ -209,7 +279,7 @@ public class CargoDiscardService {
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
|
||||
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
|
||||
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages()))
|
||||
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value)))
|
||||
.toList();
|
||||
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
|
||||
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
|
||||
@@ -244,9 +314,10 @@ public class CargoDiscardService {
|
||||
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
|
||||
String historyRemark = request.remark();
|
||||
if (REJECTED.equals(next)) {
|
||||
release(customerId, links, applicationItems);
|
||||
release(customerId, application, links, applicationItems);
|
||||
}
|
||||
|
||||
if (APPROVED.equals(next)) outboundService.create(application, operatorId);
|
||||
LocalDateTime reviewedAt = now();
|
||||
application.setStatus(next); application.setReviewedBy(operatorId);
|
||||
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
|
||||
@@ -266,12 +337,13 @@ public class CargoDiscardService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) {
|
||||
public CargoDiscardResponses.Detail confirmCompletion(String customerId, String discardNo, String operatorId) {
|
||||
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
|
||||
if (application == null) throw notFound("Cargo discard application not found");
|
||||
if (COMPLETED.equals(application.getStatus())) return detail(customerId, discardNo);
|
||||
if (!PDA_COMPLETED.equals(application.getStatus())) throw conflict("全部托盘 PDA 作业完成后才能确认废弃完成");
|
||||
updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "仓库确认废弃完成");
|
||||
application.setCustomerConfirmedAt(now());
|
||||
updateLinkedStatus(customerId, application, PDA_COMPLETED, COMPLETED, operatorId, "客户确认废弃完成");
|
||||
return detail(customerId, discardNo);
|
||||
}
|
||||
|
||||
@@ -287,6 +359,7 @@ public class CargoDiscardService {
|
||||
LastlegOrderStatusHistory h = new LastlegOrderStatusHistory(); h.setId(IdWorker.getId()); h.setCustomerId(customerId); h.setOrderId(linked.getId());
|
||||
h.setFromStatus(linkedFrom); h.setToStatus(linkedTo); h.setEventId("CARGO_DISCARD_STATUS:" + application.getId() + ":" + linkedTo); h.setRemark(remark); h.setCreatedAt(at); orderHistories.insert(h);
|
||||
}
|
||||
outboundService.sync(application);
|
||||
history(customerId, application.getId(), from, to, operatorId, remark, at);
|
||||
}
|
||||
|
||||
@@ -304,7 +377,7 @@ public class CargoDiscardService {
|
||||
throw conflict("客户只能取消待审核的废弃申请");
|
||||
}
|
||||
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
|
||||
release(customerId, links, items.listApplication(customerId, application.getId()));
|
||||
release(customerId, application, links, items.listApplication(customerId, application.getId()));
|
||||
LocalDateTime at = now();
|
||||
String from = application.getStatus();
|
||||
application.setStatus(CANCELLED); application.setReviewedBy(platform ? operatorId : application.getReviewedBy());
|
||||
@@ -318,6 +391,7 @@ public class CargoDiscardService {
|
||||
h.setFromStatus(from); h.setToStatus(CANCELLED); h.setEventId("CARGO_DISCARD_CANCEL:" + application.getId()); h.setRemark("废弃申请已取消"); h.setCreatedAt(at);
|
||||
orderHistories.insert(h);
|
||||
}
|
||||
if (APPROVED.equals(from)) outboundService.sync(application);
|
||||
history(customerId, application.getId(), from, CANCELLED, operatorId, "废弃申请已取消", at);
|
||||
return detail(customerId, discardNo);
|
||||
}
|
||||
@@ -325,11 +399,14 @@ public class CargoDiscardService {
|
||||
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
|
||||
CargoDiscardRequests.Item input, LocalDateTime now) {
|
||||
String skuCode = input.skuCode().trim();
|
||||
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
|
||||
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
|
||||
List<DropshipCarton> selected;
|
||||
if (input.cartonIds() != null) {
|
||||
selected = inventory.lockSelected(customerId, order.getId(), skuCode, input.cartonIds());
|
||||
} else {
|
||||
if (inventory.countAvailable(customerId, order.getId(), skuCode) != input.discardPackages()) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
|
||||
selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
|
||||
}
|
||||
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
|
||||
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
|
||||
if (selected.size() != input.discardPackages()) throw conflict("箱子已被占用、SKU 不匹配或不属于当前库存,请刷新后重选");
|
||||
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
|
||||
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
|
||||
java.util.stream.Collectors.toList()));
|
||||
@@ -343,6 +420,7 @@ public class CargoDiscardService {
|
||||
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
|
||||
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
|
||||
customerId, order.getId(), entry.getKey(), skuCode)));
|
||||
item.setCartonScanRequired(input.cartonIds() != null && entry.getValue().size() < inventory.availableOrderCartons(customerId, order.getId()).stream().filter(c -> c.getProfileId().equals(entry.getKey())).count());
|
||||
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
|
||||
for (DropshipCarton carton : entry.getValue()) {
|
||||
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
|
||||
@@ -356,12 +434,15 @@ public class CargoDiscardService {
|
||||
}
|
||||
}
|
||||
|
||||
private void release(String customerId, List<CargoDiscardCarton> links, List<CargoDiscardItem> applicationItems) {
|
||||
private void release(String customerId, CargoDiscardApplication application, List<CargoDiscardCarton> links, List<CargoDiscardItem> applicationItems) {
|
||||
Set<Long> genericItems = applicationItems.stream().filter(i -> "INVENTORY".equals(i.getSourceType())).map(CargoDiscardItem::getId).collect(java.util.stream.Collectors.toSet());
|
||||
for (CargoDiscardCarton link : links) {
|
||||
if (genericItems.contains(link.getItemId())) continue;
|
||||
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
|
||||
throw conflict("Reserved cargo status changed before rejection");
|
||||
}
|
||||
}
|
||||
applicationItems.stream().filter(i -> "INVENTORY".equals(i.getSourceType())).forEach(i -> stockService.release(customerId, application, i));
|
||||
applicationItems.stream().filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId)
|
||||
.filter(java.util.Objects::nonNull).distinct().forEach(cartonId -> {
|
||||
if (returnCartons.releaseDiscard(cartonId, customerId) != 1) {
|
||||
@@ -373,7 +454,7 @@ public class CargoDiscardService {
|
||||
private void reserveReturn(String customerId, CargoDiscardApplication application, ReturnInventoryCarton carton,
|
||||
CargoDiscardRequests.Item input, LocalDateTime now, Set<Long> reservedCartons) {
|
||||
ReturnInventoryCartonItem sku = returnCartonItems.lockSku(carton.getId(), input.skuCode().trim());
|
||||
if (sku == null || value(sku.getQuantity()) < input.discardPackages()) {
|
||||
if (sku == null || value(sku.getQuantity()) != input.discardPackages()) {
|
||||
throw conflict("Available return inventory changed; retry the discard request");
|
||||
}
|
||||
if (reservedCartons.add(carton.getId()) && returnCartons.markDiscardPending(carton.getId(), customerId) != 1) {
|
||||
@@ -410,10 +491,26 @@ public class CargoDiscardService {
|
||||
}
|
||||
|
||||
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
|
||||
return summary(value, StringUtils.hasText(value.getOverseasWarehouseName()) ? Map.of() : warehouseNames());
|
||||
}
|
||||
|
||||
private Map<String, String> warehouseNames() {
|
||||
var response = warehouseDirectory.receivingWarehouses(internalToken);
|
||||
if (response == null || !response.successful() || response.data() == null)
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "海外仓库目录暂不可用");
|
||||
Map<String, String> names = new HashMap<>();
|
||||
for (var warehouse : response.data()) {
|
||||
if (warehouse.warehouseId() != null && StringUtils.hasText(warehouse.warehouseName()))
|
||||
names.put(warehouse.warehouseId().toString(), warehouse.warehouseName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map<String, String> names) {
|
||||
return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
|
||||
value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(),
|
||||
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||
value.getReviewedAt());
|
||||
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
|
||||
}
|
||||
|
||||
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.baiyue.lastleg.discard.service;
|
||||
|
||||
import com.baiyue.lastleg.discard.dto.*;
|
||||
import com.baiyue.lastleg.discard.entity.*;
|
||||
import com.baiyue.lastleg.discard.mapper.*;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.entity.*;
|
||||
import com.baiyue.lastleg.inventory.mapper.*;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Stock eligibility is based on physical available positions, independent of the source document type. */
|
||||
@Service
|
||||
public class CargoDiscardStockService {
|
||||
@Resource private CargoDiscardInventoryMapper stock;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private InventoryBalanceMapper balances;
|
||||
@Resource private InventoryCommandService commands;
|
||||
@Resource private CargoDiscardItemMapper items;
|
||||
@Resource private CargoDiscardCartonMapper links;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public List<CargoDiscardResponses.Candidate> candidates(String customer, String keyword) {
|
||||
return stock.genericPositions(customer, keyword).stream().map(position -> {
|
||||
var balance = balances.selectById(position.getBalanceId());
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在");
|
||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||
var cartons = stock.genericCartons(customer, position.getId());
|
||||
boolean complete = cartons.size() == available && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank())
|
||||
&& cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size();
|
||||
var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(),
|
||||
List.of(new CargoDiscardResponses.CandidateSku(c.getSkuCode(), balance.getProductNameZh(), 1)))).toList() : List.<CargoDiscardResponses.Box>of();
|
||||
return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null,
|
||||
balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(),
|
||||
balance.getOverseasWarehouseName(), position.getCreatedAt(), available,
|
||||
List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)),
|
||||
position.getId(), position.getPalletNo(), boxes, complete,
|
||||
complete ? null : "当前库存缺少完整箱唛与托盘对应记录,可按全部库存废弃");
|
||||
}).filter(c -> c.availablePackages() > 0).toList();
|
||||
}
|
||||
|
||||
public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) {
|
||||
var position = position(customer, request.inventoryPositionId());
|
||||
var balance = balances.selectById(position.getBalanceId());
|
||||
if (balance == null || !customer.equals(balance.getCustomerId())
|
||||
|| !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId()))
|
||||
throw conflict("库存不属于当前客户或仓库");
|
||||
if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存");
|
||||
long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()));
|
||||
if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足");
|
||||
if (request.cartonIds() != null) {
|
||||
if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size())
|
||||
throw conflict("废弃箱子数量与所选箱子不一致或重复");
|
||||
var cartons = stock.genericCartons(customer, position.getId());
|
||||
var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet());
|
||||
if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选");
|
||||
} else if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量");
|
||||
if (stock.isDropshipPosition(customer, position.getId()) > 0) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留");
|
||||
return balance;
|
||||
}
|
||||
|
||||
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
|
||||
if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
|
||||
var balance = validate(customer, request);
|
||||
var position = position(customer, request.inventoryPositionId());
|
||||
var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId());
|
||||
line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId());
|
||||
line.setInboundCode(balance.getInboundCode()); line.setSkuCode(sku(balance)); line.setProductName(balance.getProductNameZh());
|
||||
line.setAvailablePackagesSnapshot(Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())))); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at);
|
||||
var availableCartons = stock.genericCartons(customer, position.getId());
|
||||
var selected = request.cartonIds() == null
|
||||
? (availableCartons.size() == request.discardPackages() ? availableCartons : List.<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow>of())
|
||||
: availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList();
|
||||
if (!selected.isEmpty()) {
|
||||
for (var carton : selected) {
|
||||
if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "EXPECTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选");
|
||||
var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId());
|
||||
link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link);
|
||||
}
|
||||
snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList());
|
||||
line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages()));
|
||||
} else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) {
|
||||
var marks = stock.looseCartonMarks(customer, position.getId());
|
||||
if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录");
|
||||
snapshotMarks(line, marks);
|
||||
}
|
||||
commands.hold(customer, operator, move(application, line, request.discardPackages(), "HOLD"));
|
||||
items.insert(line);
|
||||
}
|
||||
|
||||
public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) {
|
||||
changeSelectedCartons(customer, application, line, "AVAILABLE");
|
||||
commands.release(customer, application.getCreatedBy(), move(application, line, line.getDiscardPackages(), "RELEASE"));
|
||||
}
|
||||
|
||||
public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) {
|
||||
if (actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出");
|
||||
changeSelectedCartons(customer, application, line, "DISCARDED");
|
||||
commands.discardHeld(customer, operator, move(application, line, actual, "DISCARD"));
|
||||
}
|
||||
|
||||
private int changeBox(String customer, Long cartonId, String source, String from, String to) {
|
||||
if ("TRANSFER".equals(source)) return stock.changeTransferCartonStatus(customer, cartonId, from, to);
|
||||
if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确");
|
||||
return stock.changeGenericCartonStatus(customer, cartonId, from, to);
|
||||
}
|
||||
private void snapshotMarks(CargoDiscardItem line, List<String> marks) {
|
||||
try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); }
|
||||
catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); }
|
||||
}
|
||||
private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) {
|
||||
for (var link : links.listApplication(customer, application.getId())) {
|
||||
if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "EXPECTED" : status) != 1)
|
||||
throw conflict("预留箱子的库存状态发生变化");
|
||||
}
|
||||
}
|
||||
private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) {
|
||||
String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId();
|
||||
return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(),
|
||||
null, cycle, cycle + ":" + action, "货物废弃 " + action);
|
||||
}
|
||||
public InventoryPosition position(String customer, Long id) {
|
||||
var position = positions.findTenantPosition(customer, id);
|
||||
if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户");
|
||||
return position;
|
||||
}
|
||||
public List<String> cartonMarks(CargoDiscardItem line) {
|
||||
if (line.getCartonMarksJson() == null) return List.of();
|
||||
try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {}); }
|
||||
catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); }
|
||||
}
|
||||
private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); }
|
||||
private static long value(Long value) { return value == null ? 0 : value; }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
+2
-2
@@ -244,7 +244,7 @@ public interface DispatchCandidateMapper {
|
||||
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
|
||||
"LEFT JOIN ll_warehouse_transfer_pallet wtp ON wtp.transfer_order_id=wto.id AND wtp.customer_id=a.customer_id " +
|
||||
"AND wtp.warehouse_pallet_id=pos.pallet_id " +
|
||||
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||
"WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||
@@ -271,7 +271,7 @@ public interface DispatchCandidateMapper {
|
||||
"FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " +
|
||||
"JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " +
|
||||
"AND c.relabel_application_id=a.id AND c.overseas_warehouse_id=a.overseas_warehouse_id " +
|
||||
"WHERE a.delivery_method='TRUCK' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||
"WHERE a.delivery_method!='EXPRESS' AND a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status='WAIT_PRE_DISPATCH' " +
|
||||
"AND c.status='RELABEL_PENDING' AND c.pallet_code IS NOT NULL " +
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
|
||||
+3
-3
@@ -116,8 +116,8 @@ public class DispatchService {
|
||||
if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
|
||||
}
|
||||
if (!"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单不是卡车派送,无需进行排车");
|
||||
if ("EXPRESS".equals(application.getDeliveryMethod())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "该换标单为快递派送,无需进行排车");
|
||||
}
|
||||
return application.getCustomerId();
|
||||
}
|
||||
@@ -598,7 +598,7 @@ public class DispatchService {
|
||||
}
|
||||
relabelBindingGuard.requireBound(application);
|
||||
relabelMeasurements.requireMeasured(application);
|
||||
if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
|
||||
if ("EXPRESS".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
|
||||
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
|
||||
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
|
||||
|
||||
+34
@@ -294,6 +294,40 @@ public class InventoryCommandService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal discard workflow: remove only stock held by the same discard item. */
|
||||
@Transactional
|
||||
public InventoryCommandResponse discardHeld(String customerId, String operatorId, InventoryCommandRequests.Move request) {
|
||||
if (request.packages() <= 0) throw badRequest("废弃数量必须为正数");
|
||||
return execute(customerId, operatorId, "DISCARD", request.idempotencyKey(), request, operation -> {
|
||||
Locked locked = locked(customerId, request.positionId());
|
||||
InventoryPosition position = locked.position();
|
||||
InventoryBalance balance = locked.balance();
|
||||
requireReservation(customerId, request.positionId(), null, request.packages(), "DISCARD");
|
||||
long owned = ledgers.orderHoldCycle(customerId, balance.getInboundOrderId(),
|
||||
required(request.sourceEventId(), "废弃库存预留标识不能为空")).stream()
|
||||
.filter(l -> Objects.equals(l.getPositionId(), position.getId())
|
||||
&& Objects.equals(l.getBusinessOrderNo(), request.businessOrderNo()))
|
||||
.mapToLong(l -> value(l.getDeltaPackages())).sum();
|
||||
requireAtLeast(owned, request.packages(), "discard reservation");
|
||||
long before = value(position.getHoldPackages());
|
||||
requireAtLeast(before, request.packages(), "held");
|
||||
requireAtLeast(value(balance.getHoldPackages()), request.packages(), "balance held");
|
||||
position.setHoldPackages(before - request.packages());
|
||||
position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED");
|
||||
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now());
|
||||
positions.updateById(position);
|
||||
balance.setHoldPackages(value(balance.getHoldPackages()) - request.packages()); touch(balance);
|
||||
balances.updateById(balance);
|
||||
boolean empty = position.getPalletId() != null && commands.palletPackages(position.getPalletId()) == 0;
|
||||
if (empty) warehouseGateway.scrapPallet(customerId, position.getPalletId());
|
||||
ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "DISCARD",
|
||||
"HOLD", "DISCARDED", before, -request.packages(), before - request.packages(),
|
||||
request.businessOrderNo(), balance.getInboundOrderNo(), request.outboundOrderNo(), request.sourceEventId(),
|
||||
request.idempotencyKey(), request.remark());
|
||||
return response(operation, balance, position, empty);
|
||||
});
|
||||
}
|
||||
|
||||
private InventoryCommandResponse ship(String customerId, String operatorId,
|
||||
InventoryCommandRequests.Move request, String skuSnapshot) {
|
||||
return ship(customerId, operatorId, request, skuSnapshot, null);
|
||||
|
||||
+3
-1
@@ -80,6 +80,7 @@ public class PdaRelabelService {
|
||||
return result;
|
||||
}
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
|
||||
public static final String AVAILABLE = "AVAILABLE";
|
||||
public static final String PROCESSING = "PROCESSING";
|
||||
public static final String WAIT_REVIEW = "WAIT_REVIEW";
|
||||
@@ -454,6 +455,7 @@ public class PdaRelabelService {
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
if (!request.manualCompletion()) bindingGuard.requireBound(application);
|
||||
palletMeasurements.requireMeasured(application);
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = factList(task);
|
||||
boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount()));
|
||||
@@ -814,7 +816,7 @@ public class PdaRelabelService {
|
||||
task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(),
|
||||
task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(),
|
||||
task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(),
|
||||
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), "TRUCK".equals(application.getDeliveryMethod()));
|
||||
itemResponses, factResponses, historyResponses, application.getDeliveryMethod(), !"EXPRESS".equals(application.getDeliveryMethod()));
|
||||
}
|
||||
|
||||
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
|
||||
|
||||
+5
-5
@@ -78,7 +78,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
|
||||
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
|
||||
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
|
||||
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
|
||||
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP");
|
||||
|
||||
@Resource private RelabelApplicationMapper applications;
|
||||
@Resource private RelabelItemMapper items;
|
||||
@@ -373,7 +373,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!requiresOutbound && StringUtils.hasText(deliveryMethod)) {
|
||||
throw badRequest("SKU-only relabel does not create an outbound fulfillment");
|
||||
}
|
||||
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS", "EXPRESS_PICKUP").contains(deliveryMethod)) {
|
||||
if (Boolean.TRUE.equals(request.printShippingLabel()) && !Set.of("EXPRESS").contains(deliveryMethod)) {
|
||||
throw badRequest("Shipping-label printing is only available for express delivery");
|
||||
}
|
||||
String requestHash = hash(request);
|
||||
@@ -951,9 +951,9 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setCustomerConfirmationKey(key);
|
||||
application.setCustomerConfirmedAt(now);
|
||||
application.setFulfillmentStatus(requiresOutbound
|
||||
? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION")
|
||||
? ("EXPRESS".equals(application.getDeliveryMethod()) ? "WAIT_OUTBOUND_CREATION" : "WAIT_PRE_DISPATCH")
|
||||
: "NOT_REQUIRED");
|
||||
if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
if (requiresOutbound && !existingStockTransfer && "EXPRESS".equals(application.getDeliveryMethod())) {
|
||||
createDirectOutbound(application, operatorId, now);
|
||||
application.setFulfillmentStatus("OUTBOUND_CREATED");
|
||||
}
|
||||
@@ -993,7 +993,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
|
||||
// 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。
|
||||
// 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。
|
||||
boolean awaitingTruck = requiresOutbound && "TRUCK".equals(application.getDeliveryMethod());
|
||||
boolean awaitingTruck = requiresOutbound && !"EXPRESS".equals(application.getDeliveryMethod());
|
||||
if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) {
|
||||
carton.setRelabelApplicationId(null); carton.setRelabelNo(null);
|
||||
}
|
||||
|
||||
+2
-2
@@ -58,7 +58,7 @@ public class RelabelBindingGuard {
|
||||
returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum));
|
||||
if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱");
|
||||
}
|
||||
if (!"TRUCK".equals(app.getDeliveryMethod())) continue;
|
||||
if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
|
||||
if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()))
|
||||
throw conflict("退货箱尚未完成排车托盘绑定");
|
||||
continue;
|
||||
@@ -76,7 +76,7 @@ public class RelabelBindingGuard {
|
||||
var actual = new HashMap<String, Long>();
|
||||
stock.forEach(i -> actual.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Long::sum));
|
||||
if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工");
|
||||
if (!"TRUCK".equals(app.getDeliveryMethod())) continue;
|
||||
if ("EXPRESS".equals(app.getDeliveryMethod())) continue;
|
||||
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
|
||||
if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|
||||
|| !"PROCESSING".equals(carton.getRelabelStatus()))
|
||||
|
||||
+13
-3
@@ -10,6 +10,13 @@ import java.util.List;
|
||||
/** Immutable movements retain original return ownership when cartons are merged again. */
|
||||
@Mapper
|
||||
public interface ReturnPackingTraceMapper {
|
||||
// Collapse fragments within one operation; separate physical operations remain separate history.
|
||||
String HISTORY_SELECT = "SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,"
|
||||
+ "t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,SUM(t.quantity) AS quantity,"
|
||||
+ "MIN(t.created_at) AS created_at FROM ll_return_packing_trace t "
|
||||
+ "JOIN ll_return_inventory_operation o ON o.id=t.operation_id ";
|
||||
String HISTORY_GROUP = " GROUP BY t.operation_id,JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')),"
|
||||
+ "t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code ";
|
||||
record Trace(long id, String sourceCartonMark, String targetCartonMark, String returnNo,
|
||||
String skuCode, long quantity, LocalDateTime createdAt) { }
|
||||
record Ownership(String returnNo, long quantity) { }
|
||||
@@ -28,15 +35,18 @@ public interface ReturnPackingTraceMapper {
|
||||
@Select("SELECT id,source_carton_mark,target_carton_mark,return_no,sku_code,quantity,created_at FROM ll_return_packing_trace WHERE customer_id=#{customerId} AND return_no=#{returnNo} ORDER BY created_at,id")
|
||||
List<Trace> listReturn(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
|
||||
|
||||
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND t.return_no=#{returnNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id")
|
||||
@Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND t.return_no=#{returnNo} AND t.source_carton_mark<>''"
|
||||
+ HISTORY_GROUP + "ORDER BY MIN(t.created_at),MIN(t.id)")
|
||||
List<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.PackingDetail> returnHistory(@Param("customerId") String customerId, @Param("returnNo") String returnNo);
|
||||
|
||||
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND t.source_carton_mark<>'' ORDER BY t.created_at,t.id")
|
||||
@Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND t.source_carton_mark<>''"
|
||||
+ HISTORY_GROUP + "ORDER BY MIN(t.created_at),MIN(t.id)")
|
||||
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> planHistory(@Param("customerId") String customerId, @Param("planNo") String planNo);
|
||||
|
||||
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.operatorName')) FROM ll_return_inventory_operation o WHERE JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo'))=#{planNo} AND o.operation_type='MERGE' ORDER BY o.id LIMIT 1")
|
||||
String planOperator(@Param("planNo") String planNo);
|
||||
|
||||
@Select("SELECT JSON_UNQUOTE(JSON_EXTRACT(o.result_json,'$.planNo')) AS plan_no,t.source_carton_mark,t.target_carton_mark,t.return_no,t.sku_code,t.quantity,t.created_at FROM ll_return_packing_trace t JOIN ll_return_inventory_operation o ON o.id=t.operation_id WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark}) ORDER BY t.created_at DESC,t.id DESC")
|
||||
@Select(HISTORY_SELECT + "WHERE t.customer_id=#{customerId} AND o.operation_type='MERGE' AND t.source_carton_mark<>'' AND (t.source_carton_mark=#{mark} OR t.target_carton_mark=#{mark})"
|
||||
+ HISTORY_GROUP + "ORDER BY MIN(t.created_at) DESC,MIN(t.id) DESC")
|
||||
List<com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses.MergeHistory> listCartonHistory(@Param("customerId") String customerId, @Param("mark") String mark);
|
||||
}
|
||||
|
||||
+7
-1
@@ -1190,7 +1190,13 @@ public class ReturnInventoryService {
|
||||
if (origins.length != 1) throw conflict("历史混箱缺少逐 SKU 来源明细,不能继续合并");
|
||||
result.add(new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(origins[0], quantity - traced));
|
||||
}
|
||||
return result;
|
||||
// Existing trace and the original untracked remainder can belong to the same return.
|
||||
// Coalesce before checking for multiple owners or recording a new movement.
|
||||
Map<String, Long> byReturn = new LinkedHashMap<>();
|
||||
for (var origin : result) byReturn.merge(origin.returnNo(), origin.quantity(), Math::addExact);
|
||||
return byReturn.entrySet().stream()
|
||||
.map(entry -> new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership(entry.getKey(), entry.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ReturnInventoryResponses.MergeResult replayMerge(ReturnInventoryOperation operation, String requestHash) {
|
||||
|
||||
@@ -58,6 +58,7 @@ public final class OverseasPermissions {
|
||||
public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm";
|
||||
public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list";
|
||||
public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create";
|
||||
public static final String MERCHANT_DISCARD_CONFIRM = "merchant:overseas-warehouse:discard:confirm";
|
||||
public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel";
|
||||
public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list";
|
||||
public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create";
|
||||
|
||||
+15
@@ -77,6 +77,21 @@ public class PdaWarehouseTransferController {
|
||||
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/carton-label")
|
||||
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.pdaCartonLabel(identity.overseasWarehouseId(), transferNo));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/cartons/count")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> countCartons(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
@Valid @RequestBody WarehouseTransferRequests.CountCartons request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.countCartons(identity.operatorKey(), identity.overseasWarehouseId(), transferNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/palletize")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
|
||||
+26
-9
@@ -97,6 +97,24 @@ public final class WarehouseTransferRequests {
|
||||
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record CountCartons(
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotEmpty @Size(max = 5000) List<@Valid @NotNull CountCarton> boxes,
|
||||
@Size(max = 1000) String discrepancyReason
|
||||
) {}
|
||||
|
||||
public record CountCarton(
|
||||
@Size(max = 255) String cartonMark,
|
||||
Long transferItemId,
|
||||
@NotNull Long transferPalletId,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal length,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal width,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal height,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal weight,
|
||||
@NotBlank @Size(max = 255) String skuCode,
|
||||
@Positive @Max(2147483647) long quantity
|
||||
) {}
|
||||
|
||||
public record Palletize(
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
|
||||
@@ -104,13 +122,7 @@ public final class WarehouseTransferRequests {
|
||||
|
||||
public record PalletDraft(
|
||||
Long palletId,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Positive int cartonCount,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal lengthCm,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal widthCm,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal heightCm,
|
||||
@NotNull @DecimalMin("0.000001") BigDecimal volumeCbm,
|
||||
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
|
||||
@NotBlank @Size(max = 255) String idempotencyKey
|
||||
) {}
|
||||
|
||||
public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {}
|
||||
@@ -151,8 +163,13 @@ public final class WarehouseTransferRequests {
|
||||
@Size(max = 128) String skuCode,
|
||||
@Size(max = 255) String productNameZh,
|
||||
@Positive int packageCount,
|
||||
@Size(max = 255) String shippingMark
|
||||
) {}
|
||||
@Size(max = 255) String shippingMark,
|
||||
@Positive Long quantity
|
||||
) {
|
||||
public Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {
|
||||
this(skuCode, productNameZh, packageCount, shippingMark, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record RelabelItem(
|
||||
@NotBlank @Size(max = 32) String operationType,
|
||||
|
||||
+4
-2
@@ -6,8 +6,10 @@ import java.util.List;
|
||||
|
||||
public final class WarehouseTransferResponses {
|
||||
private WarehouseTransferResponses() {}
|
||||
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark) {}
|
||||
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status) {}
|
||||
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {}
|
||||
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status,
|
||||
Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height,
|
||||
BigDecimal weight, Long quantity, String discrepancyReason, String countedBy, LocalDateTime countedAt) {}
|
||||
public record Label(String transferNo, List<Carton> cartons, String printableHtml) {}
|
||||
public record DestinationResult(String customerId, String transferNo) {}
|
||||
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
|
||||
|
||||
+11
@@ -13,12 +13,23 @@ public class WarehouseTransferCarton {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long transferOrderId;
|
||||
private Long transferItemId;
|
||||
private Long transferPalletId;
|
||||
private String customerId;
|
||||
private String cartonMark;
|
||||
private String customerCartonMark;
|
||||
private Integer cartonIndex;
|
||||
private Integer totalCartons;
|
||||
private String status;
|
||||
private java.math.BigDecimal lengthCm;
|
||||
private java.math.BigDecimal widthCm;
|
||||
private java.math.BigDecimal heightCm;
|
||||
private java.math.BigDecimal grossWeightKg;
|
||||
private Long countedQuantity;
|
||||
private String countIdempotencyKey;
|
||||
private String countRequestHash;
|
||||
private String discrepancyReason;
|
||||
private String countedBy;
|
||||
private LocalDateTime countedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ public class WarehouseTransferItem {
|
||||
private String skuCode;
|
||||
private String productNameZh;
|
||||
private Integer packageCount;
|
||||
private Long declaredQuantity;
|
||||
private String shippingMark;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+133
-42
@@ -525,7 +525,7 @@ public class WarehouseTransferService {
|
||||
new LambdaQueryWrapper<WarehouseTransferItem>().eq(WarehouseTransferItem::getCustomerId, customerId)
|
||||
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId))
|
||||
.stream().map(value -> new WarehouseTransferResponses.Item(value.getId(), value.getSkuCode(), value.getProductNameZh(),
|
||||
value.getPackageCount(), value.getShippingMark())).toList();
|
||||
value.getPackageCount(), value.getShippingMark(), value.getDeclaredQuantity())).toList();
|
||||
Map<Long, WarehouseTransferResponses.Item> itemById = items.stream()
|
||||
.collect(Collectors.toMap(WarehouseTransferResponses.Item::id, Function.identity()));
|
||||
List<WarehouseTransferResponses.SourceOrder> sources = sourceOrders.selectList(
|
||||
@@ -535,7 +535,7 @@ public class WarehouseTransferService {
|
||||
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
|
||||
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId())
|
||||
.stream().map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(),
|
||||
value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
|
||||
value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
|
||||
List<WarehouseTransferPalletItem> palletItemRows = transferPalletItems == null
|
||||
? List.of() : transferPalletItems.listOrder(customerId, transfer.getId());
|
||||
Map<Long, List<WarehouseTransferPalletItem>> palletItemsByPallet = palletItemRows.stream()
|
||||
@@ -607,13 +607,21 @@ public class WarehouseTransferService {
|
||||
transfer.getCustomerConfirmedAt(), transfer.getOutboundAt(), transfer.getCreatedAt());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public WarehouseTransferResponses.Label pdaCartonLabel(long warehouseId, String transferNo) {
|
||||
var transfer = transfers.findNoByWarehouse(transferNo, String.valueOf(warehouseId));
|
||||
if (transfer == null) throw notFound("当前仓没有该转运单");
|
||||
return cartonLabel(transfer.getCustomerId(), transferNo);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public WarehouseTransferResponses.Label cartonLabel(String customerId, String transferNo) {
|
||||
WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo);
|
||||
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||
if (DRAFT.equals(transfer.getStatus())) throw conflict("Submit the warehouse transfer order before printing carton labels");
|
||||
List<WarehouseTransferResponses.Carton> cartons = transferCartons.listOrder(customerId, transfer.getId()).stream()
|
||||
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus())).toList();
|
||||
.filter(value -> !"NOT_RECEIVED".equals(value.getStatus()))
|
||||
.map(value -> new WarehouseTransferResponses.Carton(value.getCartonMark(), value.getCustomerCartonMark(), value.getCartonIndex(), value.getTotalCartons(), value.getStatus(), value.getTransferItemId(), value.getTransferPalletId(), value.getLengthCm(), value.getWidthCm(), value.getHeightCm(), value.getGrossWeightKg(), value.getCountedQuantity(), value.getDiscrepancyReason(), value.getCountedBy(), value.getCountedAt())).toList();
|
||||
StringBuilder labels = new StringBuilder();
|
||||
for (WarehouseTransferResponses.Carton carton : cartons) {
|
||||
labels.append("<section class=label><div class=title>仓储转运箱唛</div><div class=mark>")
|
||||
@@ -689,9 +697,10 @@ public class WarehouseTransferService {
|
||||
WarehouseTransferRequests.PalletDraft request) {
|
||||
WarehouseTransferOrder transfer = requirePdaMeasuringTransfer(warehouseId, transferNo);
|
||||
String customerId = transfer.getCustomerId();
|
||||
validatePalletMeasurement(request.lengthCm(), request.widthCm(), request.heightCm(), request.volumeCbm());
|
||||
String requestHash = hash(request);
|
||||
List<WarehouseTransferPallet> existing = transferPallets.listOrder(customerId, transfer.getId());
|
||||
if (transferCartons.listOrder(customerId, transfer.getId()).stream().anyMatch(box -> box.getCountedAt() != null))
|
||||
throw conflict("箱子清点已确认,不能再新增或修改托盘");
|
||||
WarehouseTransferPallet pallet;
|
||||
if (request.palletId() == null) {
|
||||
pallet = existing.stream().filter(value -> request.idempotencyKey().equals(value.getDraftIdempotencyKey()))
|
||||
@@ -710,9 +719,9 @@ public class WarehouseTransferService {
|
||||
if (pallet == null) throw notFound("Warehouse transfer pallet draft not found");
|
||||
if (!"DRAFT".equals(pallet.getStatus())) throw conflict("Generated pallet measurements are locked");
|
||||
}
|
||||
pallet.setCartonCount(request.cartonCount()); pallet.setLengthCm(request.lengthCm());
|
||||
pallet.setWidthCm(request.widthCm()); pallet.setHeightCm(request.heightCm());
|
||||
pallet.setVolumeCbm(request.volumeCbm()); pallet.setGrossWeightKg(request.grossWeightKg());
|
||||
pallet.setCartonCount(0); pallet.setLengthCm(BigDecimal.ZERO);
|
||||
pallet.setWidthCm(BigDecimal.ZERO); pallet.setHeightCm(BigDecimal.ZERO);
|
||||
pallet.setVolumeCbm(BigDecimal.ZERO); pallet.setGrossWeightKg(BigDecimal.ZERO);
|
||||
pallet.setDraftIdempotencyKey(request.idempotencyKey()); pallet.setDraftRequestHash(requestHash);
|
||||
pallet.setUpdatedAt(LocalDateTime.now()); transferPallets.insertOrUpdate(pallet);
|
||||
fleetPool.receiving(customerId, transferNo, pdaOperatorId());
|
||||
@@ -782,6 +791,94 @@ public class WarehouseTransferService {
|
||||
return detail(transfer.getCustomerId(), transferNo);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public WarehouseTransferResponses.Detail countCartons(String operatorId, long warehouseId, String transferNo,
|
||||
WarehouseTransferRequests.CountCartons request) {
|
||||
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
|
||||
if (transfer == null) throw notFound("当前仓没有该转运单");
|
||||
String customerId = transfer.getCustomerId();
|
||||
List<WarehouseTransferCarton> cartons = transferCartons.listOrder(customerId, transfer.getId());
|
||||
String requestHash = hash(request);
|
||||
boolean sameKey = cartons.stream().anyMatch(box -> request.idempotencyKey().equals(box.getCountIdempotencyKey()));
|
||||
if (sameKey) {
|
||||
if (cartons.stream().anyMatch(box -> !request.idempotencyKey().equals(box.getCountIdempotencyKey())
|
||||
|| !requestHash.equals(box.getCountRequestHash()))) throw conflict("幂等键已用于其他清点内容");
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("转运单已结束入库测量");
|
||||
if (cartons.stream().anyMatch(box -> box.getCountedAt() != null)) throw conflict("本单已完成清点,请刷新查看结果");
|
||||
if (cartons.isEmpty()) throw conflict("转运单缺少箱号预报");
|
||||
if (request.boxes().size() != transfer.getPackageCount() && !StringUtils.hasText(request.discrepancyReason()))
|
||||
throw badRequest("实收箱数与预报不一致时必须填写差异原因");
|
||||
Map<String, WarehouseTransferCarton> byMark = cartons.stream().collect(Collectors.toMap(WarehouseTransferCarton::getCartonMark, Function.identity()));
|
||||
Map<Long, WarehouseTransferItem> byItem = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
|
||||
.eq(WarehouseTransferItem::getCustomerId, customerId).eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()))
|
||||
.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity()));
|
||||
List<WarehouseTransferPallet> countedPallets = transferPallets.listOrder(customerId, transfer.getId());
|
||||
if (countedPallets.stream().anyMatch(pallet -> request.boxes().stream().noneMatch(box -> pallet.getId().equals(box.transferPalletId()))))
|
||||
throw badRequest("每个托盘必须至少分配一个实收箱,请核对箱子所属托盘");
|
||||
Map<Long, WarehouseTransferPallet> byPallet = countedPallets.stream().collect(Collectors.toMap(WarehouseTransferPallet::getId, Function.identity()));
|
||||
Set<String> seen = new java.util.HashSet<>();
|
||||
for (var input : request.boxes()) {
|
||||
var targetPallet = byPallet.get(input.transferPalletId());
|
||||
if (targetPallet == null || targetPallet.getWarehousePalletId() == null
|
||||
|| !Set.of("GENERATED", "BOUND").contains(targetPallet.getStatus()))
|
||||
throw badRequest("请先创建本单托盘号,再选择箱子所属托盘");
|
||||
var carton = byMark.get(input.cartonMark());
|
||||
if (StringUtils.hasText(input.cartonMark()) && (carton == null || !seen.add(input.cartonMark())))
|
||||
throw badRequest("箱号不属于本单或重复填写");
|
||||
if (carton == null && input.transferItemId() == null) throw badRequest("新增实收箱必须选择本单申报明细");
|
||||
var item = byItem.get(carton == null ? input.transferItemId() : carton.getTransferItemId());
|
||||
if (item == null || !item.getSkuCode().equals(input.skuCode())) throw badRequest("箱内 SKU 必须与该箱申报明细一致");
|
||||
if (input.quantity() <= 0 || input.quantity() > Integer.MAX_VALUE || input.length() == null || input.width() == null
|
||||
|| input.height() == null || input.weight() == null || input.length().signum() <= 0 || input.width().signum() <= 0
|
||||
|| input.height().signum() <= 0 || input.weight().signum() <= 0) throw badRequest("每箱必须填写有效尺寸、重量和实点件数");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (var carton : cartons) {
|
||||
if (seen.contains(carton.getCartonMark())) continue;
|
||||
carton.setStatus("NOT_RECEIVED"); carton.setCountedAt(now); carton.setCountedBy(operatorId);
|
||||
carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash);
|
||||
carton.setDiscrepancyReason(trim(request.discrepancyReason())); carton.setUpdatedAt(now); transferCartons.updateById(carton);
|
||||
}
|
||||
int nextIndex = cartons.stream().mapToInt(WarehouseTransferCarton::getCartonIndex).max().orElse(0);
|
||||
for (var input : request.boxes()) {
|
||||
var carton = byMark.get(input.cartonMark());
|
||||
boolean added = carton == null;
|
||||
if (added) {
|
||||
carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId());
|
||||
carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId);
|
||||
carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount());
|
||||
carton.setCartonMark(transferNo + "#EX-" + nextIndex); carton.setCreatedAt(now);
|
||||
}
|
||||
carton.setTransferPalletId(input.transferPalletId());
|
||||
carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height());
|
||||
carton.setGrossWeightKg(input.weight()); carton.setCountedQuantity(input.quantity());
|
||||
carton.setCountedBy(operatorId); carton.setCountedAt(now); carton.setStatus("COUNTED");
|
||||
carton.setCountIdempotencyKey(request.idempotencyKey()); carton.setCountRequestHash(requestHash); carton.setUpdatedAt(now);
|
||||
carton.setDiscrepancyReason(trim(request.discrepancyReason()));
|
||||
if (added) transferCartons.insert(carton); else transferCartons.updateById(carton);
|
||||
}
|
||||
// Aggregate only the submitted, physically assigned boxes; never infer SKU placement from declaration order.
|
||||
for (var pallet : countedPallets) {
|
||||
var assigned = request.boxes().stream().filter(box -> pallet.getId().equals(box.transferPalletId())).toList();
|
||||
pallet.setCartonCount(assigned.size());
|
||||
pallet.setVolumeCbm(assigned.stream().map(box -> box.length().multiply(box.width()).multiply(box.height())
|
||||
.movePointLeft(6)).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(6, java.math.RoundingMode.HALF_UP));
|
||||
pallet.setGrossWeightKg(assigned.stream().map(WarehouseTransferRequests.CountCarton::weight).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
pallet.setUpdatedAt(now); transferPallets.updateById(pallet);
|
||||
}
|
||||
return detail(customerId, transferNo);
|
||||
}
|
||||
|
||||
private List<WarehouseTransferCarton> requireCountedCartons(WarehouseTransferOrder transfer) {
|
||||
var cartons = transferCartons.listOrder(transfer.getCustomerId(), transfer.getId());
|
||||
if (cartons.isEmpty() || cartons.stream().anyMatch(box -> box.getCountedAt() == null)
|
||||
|| cartons.stream().noneMatch(box -> "COUNTED".equals(box.getStatus())))
|
||||
throw conflict("请先完成全部箱子的尺寸测量和箱内清点");
|
||||
return cartons.stream().filter(box -> "COUNTED".equals(box.getStatus())).toList();
|
||||
}
|
||||
|
||||
private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) {
|
||||
WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId));
|
||||
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||
@@ -818,6 +915,7 @@ public class WarehouseTransferService {
|
||||
if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) {
|
||||
throw conflict("Warehouse transfer is not awaiting PDA receiving");
|
||||
}
|
||||
List<WarehouseTransferCarton> countedCartons = requireCountedCartons(transfer);
|
||||
List<WarehouseTransferItem> items = transferItems.selectList(new LambdaQueryWrapper<WarehouseTransferItem>()
|
||||
.eq(WarehouseTransferItem::getCustomerId, customerId)
|
||||
.eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId));
|
||||
@@ -838,8 +936,10 @@ public class WarehouseTransferService {
|
||||
if (persistedPallets.isEmpty() || persistedPallets.stream().anyMatch(value -> !"BOUND".equals(value.getStatus()))) {
|
||||
throw conflict("Every pallet must be generated and scan-bound before completing palletization");
|
||||
}
|
||||
if (persistedPallets.stream().anyMatch(pallet -> pallet.getCartonCount() == null || pallet.getCartonCount() <= 0)) throw conflict("每个托盘必须至少分配一个实收箱");
|
||||
int palletCartons = persistedPallets.stream().mapToInt(WarehouseTransferPallet::getCartonCount).sum();
|
||||
List<DerivedPalletAllocation> derivedAllocations = derivePersistedPalletAllocations(items, persistedPallets);
|
||||
if (palletCartons != countedCartons.size()) throw conflict("打托箱数必须与已清点实收箱数一致");
|
||||
List<DerivedPalletAllocation> derivedAllocations = deriveCountedPalletAllocations(countedCartons, persistedPallets);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<WarehouseTransferPalletItem> completedPalletItems = new ArrayList<>();
|
||||
for (int index = 0; index < persistedPallets.size(); index++) {
|
||||
@@ -1452,29 +1552,20 @@ public class WarehouseTransferService {
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<DerivedPalletAllocation> derivePersistedPalletAllocations(List<WarehouseTransferItem> items,
|
||||
List<WarehouseTransferPallet> pallets) {
|
||||
if (items == null || items.isEmpty()) throw conflict("Warehouse transfer declared cargo is incomplete");
|
||||
static List<DerivedPalletAllocation> deriveCountedPalletAllocations(List<WarehouseTransferCarton> cartons,
|
||||
List<WarehouseTransferPallet> pallets) {
|
||||
List<DerivedPalletAllocation> result = new ArrayList<>();
|
||||
int itemIndex = 0;
|
||||
int itemRemaining = items.get(0).getPackageCount();
|
||||
for (int palletIndex = 0; palletIndex < pallets.size(); palletIndex++) {
|
||||
int palletRemaining = pallets.get(palletIndex).getCartonCount();
|
||||
while (palletRemaining > 0) {
|
||||
WarehouseTransferItem item = items.get(itemIndex);
|
||||
if (itemIndex + 1 == items.size()) {
|
||||
result.add(new DerivedPalletAllocation(palletIndex, item.getId(), palletRemaining));
|
||||
break;
|
||||
}
|
||||
int allocated = Math.min(palletRemaining, itemRemaining);
|
||||
result.add(new DerivedPalletAllocation(palletIndex, item.getId(), allocated));
|
||||
palletRemaining -= allocated;
|
||||
itemRemaining -= allocated;
|
||||
if (itemRemaining == 0 && itemIndex + 1 < items.size()) {
|
||||
itemIndex++;
|
||||
itemRemaining = items.get(itemIndex).getPackageCount();
|
||||
}
|
||||
}
|
||||
Set<Long> palletIds = pallets.stream().map(WarehouseTransferPallet::getId).collect(Collectors.toSet());
|
||||
if (cartons.stream().anyMatch(box -> box.getTransferPalletId() == null || !palletIds.contains(box.getTransferPalletId())))
|
||||
throw conflict("每个实收箱必须分配到本单托盘");
|
||||
for (int index = 0; index < pallets.size(); index++) {
|
||||
var pallet = pallets.get(index);
|
||||
Map<Long, Integer> quantities = new java.util.LinkedHashMap<>();
|
||||
cartons.stream().filter(box -> pallet.getId().equals(box.getTransferPalletId()))
|
||||
.forEach(box -> quantities.merge(box.getTransferItemId(), 1, Integer::sum));
|
||||
if (quantities.values().stream().mapToInt(Integer::intValue).sum() != pallet.getCartonCount())
|
||||
throw conflict("托盘箱数与分配的实收箱不一致");
|
||||
for (var entry : quantities.entrySet()) result.add(new DerivedPalletAllocation(index, entry.getKey(), entry.getValue()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1796,18 +1887,17 @@ public class WarehouseTransferService {
|
||||
pallet.setWarehouseResourceLockId(source.getWarehouseResourceLockId()); pallet.setAssignmentStatus("ASSIGNED");
|
||||
pallet.setLockedAt(source.getCompletedAt()); pallet.setActualVolumeCbm(source.getVolumeCbm());
|
||||
pallet.setActualPackageCount(source.getCartonCount()); inboundPlanPallets.insert(pallet);
|
||||
for (WarehouseTransferPalletItem sourceItem : palletItems.stream()
|
||||
.filter(row -> row.getTransferPalletId().equals(source.getId())).toList()) {
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
||||
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
|
||||
allocation.setPlannedPackageCount(sourceItem.getPackageCount()); allocation.setCountedPackageCount(sourceItem.getPackageCount());
|
||||
// PDA 已完成测量、打托并确认了本托箱数;入仓计划列表的“实际拣货数”
|
||||
// 直接汇总该字段,不能再保留初始化时的 0。
|
||||
allocation.setPickedPackageCount(sourceItem.getPackageCount());
|
||||
allocation.setActualVolumeCbm(source.getVolumeCbm());
|
||||
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
|
||||
}
|
||||
// Plan allocations are unique by inbound line + pallet. SKU breakdown
|
||||
// belongs to transfer pallet items and must not duplicate that allocation.
|
||||
int receivedPackages = palletItems.stream().filter(row -> row.getTransferPalletId().equals(source.getId()))
|
||||
.mapToInt(WarehouseTransferPalletItem::getPackageCount).sum();
|
||||
if (receivedPackages != source.getCartonCount()) throw conflict("托盘明细箱数与实收不一致");
|
||||
InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId());
|
||||
allocation.setCustomerId(customerId); allocation.setPlanId(plan.getId()); allocation.setLineId(line.getId());
|
||||
allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(BigDecimal.ZERO);
|
||||
allocation.setPlannedPackageCount(receivedPackages); allocation.setCountedPackageCount(receivedPackages);
|
||||
allocation.setPickedPackageCount(receivedPackages); allocation.setActualVolumeCbm(source.getVolumeCbm());
|
||||
allocation.setStatus("COMPLETED"); allocation.setVersion(0); inboundPlanAllocations.insert(allocation);
|
||||
}
|
||||
plan.setAssignedFlag(true); plan.setStatus("COMPLETED"); plan.setPalletCount(pallets.size());
|
||||
plan.setTotalVolumeCbm(pallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
|
||||
@@ -1879,6 +1969,7 @@ public class WarehouseTransferService {
|
||||
item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId);
|
||||
item.setSkuCode(Objects.toString(trim(source.skuCode()), "UNSPECIFIED"));
|
||||
item.setProductNameZh(Objects.toString(trim(source.productNameZh()), "仓储转运货物"));
|
||||
item.setDeclaredQuantity(source.quantity());
|
||||
item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now);
|
||||
transferItems.insert(item);
|
||||
return item.getId();
|
||||
|
||||
Reference in New Issue
Block a user