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();
|
||||
|
||||
@@ -683,7 +683,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
|
||||
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
|
||||
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
|
||||
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、BULK_RECEIVED、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:未绑定UNASSIGNED、已绑托ON_PALLET、整票收货BULK_RECEIVED、可用AVAILABLE、待废弃DISCARD_PENDING、已废弃DISCARDED', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
|
||||
@@ -2647,15 +2647,19 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON',
|
||||
order_id BIGINT NULL COMMENT '一件代发订单ID', source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON、通用库存INVENTORY',
|
||||
return_carton_id BIGINT NULL COMMENT '退货库存箱ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NULL COMMENT '托盘档案ID',
|
||||
sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称',
|
||||
available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '申请废弃箱数',
|
||||
inventory_position_id BIGINT NULL COMMENT '废弃来源库存仓位ID',
|
||||
carton_marks_json TEXT NULL COMMENT '本次废弃所选真实箱唛快照JSON数组',
|
||||
carton_scan_required TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要逐箱核对本次所选箱子',
|
||||
completion_photo_file_ids TEXT NULL COMMENT 'PDA完工照片文件ID JSON数组',
|
||||
actual_discard_packages INT NULL COMMENT 'PDA确认实际废弃箱数', pda_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PDA处理状态:待处理PENDING、已完成COMPLETED',
|
||||
pda_operator_id VARCHAR(128) NULL COMMENT 'PDA操作人', pda_completed_at DATETIME(3) NULL COMMENT 'PDA完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code),
|
||||
UNIQUE KEY uk_ll_discard_position_sku(application_id,inventory_position_id,sku_code),
|
||||
KEY idx_ll_cargo_discard_item_list(customer_id,application_id),
|
||||
KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code)
|
||||
) ENGINE=InnoDB COMMENT='货物废弃SKU明细';
|
||||
@@ -2663,6 +2667,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_item (
|
||||
CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛',
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id),
|
||||
KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id),
|
||||
@@ -3090,7 +3095,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>=0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量';
|
||||
-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_outbound_movement (
|
||||
@@ -4026,6 +4031,37 @@ CREATE TABLE ll_selfop_product (
|
||||
KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本';
|
||||
|
||||
-- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='height_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN height_cm DECIMAL(12,2) NULL COMMENT ''箱子实测高度厘米'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN gross_weight_kg DECIMAL(12,2) NULL COMMENT ''箱子实测重量千克'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_quantity BIGINT NULL COMMENT ''箱内SKU实点件数'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_idempotency_key VARCHAR(255) NULL COMMENT ''箱内清点幂等键'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='count_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN count_request_hash VARCHAR(64) NULL COMMENT ''箱内清点请求摘要'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_by'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_by VARCHAR(128) NULL COMMENT ''箱内清点操作人'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='counted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN counted_at DATETIME(3) NULL COMMENT ''箱内清点完成时间'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN discrepancy_reason VARCHAR(1000) NULL COMMENT ''实收与预报箱数差异原因'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='transfer_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN transfer_pallet_id BIGINT NULL COMMENT ''签收箱所属转运托盘事实ID'''));
|
||||
PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt;
|
||||
|
||||
-- SKU 申报件数独立于预计箱数;历史未申报件数保持 NULL,实收以 PDA 清点为准。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='declared_quantity'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD COLUMN declared_quantity BIGINT NULL COMMENT ''SKU申报件数,非预计或实收箱数'''));
|
||||
PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt;
|
||||
|
||||
-- 历史库存基线;执行前必须取得指定环境的建表授权。
|
||||
CREATE TABLE IF NOT EXISTS ll_selfop_inventory_flow (
|
||||
id BIGINT NOT NULL COMMENT '应用生成的流水记录标识',
|
||||
|
||||
@@ -9,9 +9,44 @@ info:
|
||||
photo sets are immutable; identical replay does not deduct stock or create evidence again.
|
||||
Platform and merchant details expose only submitted photos, including photos of completed
|
||||
pallets while other pallets are still pending. Historic applications are not backfilled.
|
||||
Approval atomically creates one DISCARD_OUTBOUND document and planned SKU/pallet snapshots.
|
||||
Platform/merchant summary and PDA pallet task include nullable outboundNo. PDA completion
|
||||
synchronizes actual quantities and OUTBOUNDED status without a second inventory deduction.
|
||||
Cancellation must use the discard application workflow. PDA resolve optionally accepts
|
||||
discardNo together with palletNo; ambiguous scans require selection of a discard application.
|
||||
Candidates include INVENTORY positions from every document type with available warehouse stock,
|
||||
plus DROPSHIP and RETURN_CARTON sources. Create items identify exactly one of orderId,
|
||||
returnCartonId or inventoryPositionId and carry selected cartonIds for partial whole-carton disposal; quantities must match those IDs.
|
||||
RETURN_CARTON selections consume every SKU in the selected physical carton.
|
||||
Candidates return cartons, cartonSelectionSupported and cartonSelectionBlockReason.
|
||||
Stock without complete carton/pallet ownership remains available for full disposal only.
|
||||
PDA tasks return scanMode PALLET or CARTON and the real cartonMarks for loose stock.
|
||||
Bound pallets require their pallet number; loose stock requires every distinct carton mark scanned,
|
||||
or confirmAllCartons=true. The loose group uses 散箱 as its API path key, never as a scan code.
|
||||
The application may select a subset of available cartons. PDA must finish every selected carton;
|
||||
short actual quantities are rejected. At least one photo remains required.
|
||||
Partial bound-pallet tasks use CARTON scanMode with the real palletNo and selected cartonMarks.
|
||||
PDA completion leaves the application in PDA_COMPLETED (waiting for customer confirmation).
|
||||
Only the authenticated merchant may confirm completion after reviewing submitted PDA photos.
|
||||
Confirmation changes the application to COMPLETED, records customerConfirmedAt, and never deducts
|
||||
inventory again. Identical repeated confirmation preserves the first timestamp and history.
|
||||
Platform users cannot close the application on behalf of the customer.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/confirm-completion:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/DiscardNo'
|
||||
post:
|
||||
summary: 客户确认废弃完结
|
||||
description: >-
|
||||
Customer scope is taken only from the authenticated merchant token.
|
||||
Requires PDA_COMPLETED; repeated COMPLETED calls return the existing detail.
|
||||
No request customerId, stock adjustment, or new photo upload is accepted.
|
||||
The client shows submitted PDA photos before the customer confirms.
|
||||
x-permissions: [merchant:overseas-warehouse:discard:confirm]
|
||||
responses:
|
||||
'200': { description: ApiResponse with updated Detail; summary contains customerConfirmedAt }
|
||||
/api/pda/v1/last-leg/cargo-discards/{discardNo}/pallets/{palletNo}/photos/files:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/DiscardNo'
|
||||
@@ -68,7 +103,16 @@ paths:
|
||||
required: [itemId, actualPackages]
|
||||
properties:
|
||||
itemId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
actualPackages: { type: integer, minimum: 0 }
|
||||
actualPackages: { type: integer, minimum: 1, description: Must equal plannedPackages; must finish the application selection }
|
||||
scannedCartonMarks:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: { type: string }
|
||||
description: All task cartonMarks must be scanned unless confirmAllCartons is true.
|
||||
confirmAllCartons:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Explicitly confirm all loose cartons without individual scans; photos remain mandatory.
|
||||
photoFileIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
|
||||
@@ -4,6 +4,67 @@ info:
|
||||
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/carton-label:
|
||||
get:
|
||||
summary: PDA读取转运箱标签
|
||||
description: 需要 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR;只可读取Token绑定仓的转运单标签,包含新增实收箱号。
|
||||
parameters:
|
||||
- {name: transferNo, in: path, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'200': {description: 统一业务响应,成功data为包含printableHtml的箱标签}
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts:
|
||||
post:
|
||||
summary: 先创建转运签收托盘草稿
|
||||
description: 仅绑定仓PDA可操作,提交幂等键创建空托盘草稿,再调用generate生成实物托盘号。不接收手工箱数、尺寸、体积和重量,箱子清点时按归属自动汇总。
|
||||
parameters:
|
||||
- {name: transferNo, in: path, required: true, schema: {type: string}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [idempotencyKey]
|
||||
properties:
|
||||
palletId: {type: integer, format: int64, nullable: true}
|
||||
idempotencyKey: {type: string, maxLength: 255}
|
||||
responses:
|
||||
'200': {description: 统一业务响应,data为含空托盘草稿的转运详情}
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/cartons/count:
|
||||
post:
|
||||
summary: 转运逐箱测量与清点
|
||||
description: 仅限 lastleg:pda:operate 与 OVERSEAS_WAREHOUSE_PDA_OPERATOR,绑定仓由统一Token确定。先创建本单托盘号,每箱选择所属托盘并按申报SKU记录实点件数,托盘体积重量由分配箱子汇总,全部实收箱子测量清点后才可完成打托入库,打托箱数与已清点实收箱数核平;短收箱保留未收到事实,超收生成系统箱号;差异需原因。事务锁定转运单;原键原载荷重试不重复写入,同键不同载荷拒绝,已确认事实不可覆盖。
|
||||
parameters:
|
||||
- {name: transferNo, in: path, required: true, schema: {type: string}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [idempotencyKey, boxes]
|
||||
properties:
|
||||
idempotencyKey: {type: string, maxLength: 255}
|
||||
discrepancyReason: {type: string, maxLength: 1000, description: 短收超收时必填差异原因}
|
||||
boxes:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 5000
|
||||
items:
|
||||
type: object
|
||||
required: [transferPalletId, length, width, height, weight, skuCode, quantity]
|
||||
properties:
|
||||
cartonMark: {type: string, maxLength: 255, nullable: true, description: 已有箱号;新增超收箱不传,由服务端生成}
|
||||
transferItemId: {type: integer, format: int64, nullable: true, description: 新增超收箱所属本单申报明细}
|
||||
transferPalletId: {type: integer, format: int64, description: 本单已生成的转运托盘事实ID,逐箱关联,托盘箱数体积重量由箱子汇总}
|
||||
length: {type: number, minimum: 0.01, description: 箱长厘米}
|
||||
width: {type: number, minimum: 0.01, description: 箱宽厘米}
|
||||
height: {type: number, minimum: 0.01, description: 箱高厘米}
|
||||
weight: {type: number, minimum: 0.01, description: 箱重千克}
|
||||
skuCode: {type: string, maxLength: 255}
|
||||
quantity: {type: integer, minimum: 1, maximum: 2147483647, description: 箱内实点件数}
|
||||
responses:
|
||||
'200': {description: 统一业务响应,成功data为转运详情,cartons含逐箱尺寸重量件数及清点操作人时间}
|
||||
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
|
||||
get:
|
||||
summary: 查询单条库存余额详情
|
||||
@@ -1214,7 +1275,7 @@ paths:
|
||||
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
|
||||
post:
|
||||
summary: 客户创建仓储转运单
|
||||
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
|
||||
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。货物 items[].quantity 为 SKU 申报件数(正整数),items[].packageCount 为预计箱数,整单 packageCount 等于明细预计箱数合计;件数与箱数独立,实际签收箱数和每箱件数以 PDA 清点事实为准,支持短收、超收并记录差异原因。目的去向可暂未确定(destinationType 为 NONE 或不传),允许保存草稿、编辑和提交;入库确认后可在出库时绑定 FBA 仓或私人仓,也可选择入库库存发起贴标申请出库;普通转运出库前必须补齐目的仓。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
|
||||
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
|
||||
@@ -1883,6 +1944,15 @@ components:
|
||||
description: 保留原转运详情字段;新增持久实收命令投影供平台与当前客户恢复。成功实收返回本地事务完成后的命令状态,不暴露内部资源锁或reservationKey。
|
||||
required: [receiptCommands]
|
||||
properties:
|
||||
packageCount: { type: integer, description: 下单预计箱数,不随实际签收覆盖 }
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
quantity: { type: integer, format: int64, nullable: true, description: SKU 申报件数,历史未申报为 null }
|
||||
packageCount: { type: integer, description: 该 SKU 预计箱数 }
|
||||
cartons: { type: array, items: { type: object }, description: 实际签收仅统计状态 COUNTED 的箱子 }
|
||||
receiptCommands:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/WarehouseTransferReceiptCommand' }
|
||||
@@ -3214,7 +3284,7 @@ components:
|
||||
chargeAmount: { type: number, nullable: true, minimum: 0.01, description: 本次贴标费用(CNY);未传时不冻结或结算资金 }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, TRUCK_PICKUP, EXPRESS_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
|
||||
deliveryMethod: { type: string, nullable: true, enum: [TRUCK, EXPRESS, AMAZON_PICKUP, CUSTOMER_PICKUP, PICKUP], description: 只换箱唛或SKU与箱唛同时换时必填;只换SKU时不传 }
|
||||
printShippingLabel: { type: boolean, default: false, description: 仅快递派送可选,客户确认前必须已上传面单 }
|
||||
items: { type: array, minItems: 1, maxItems: 500, items: { $ref: '#/components/schemas/RelabelItem' } }
|
||||
RelabelItem:
|
||||
|
||||
@@ -415,9 +415,10 @@ paths:
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
|
||||
post:
|
||||
summary: 提交现场任务到复核池并直接等待客户确认
|
||||
description: 仓储转运换标须先确认所有涉及托盘的有效实测尺寸和重量,含拆托后原托盘剩余货物;手动完成也不能跳过实测校验。缺失或库存版本变化导致实测失效时,保持处理中并提示补测。
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
|
||||
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } }
|
||||
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 托盘实测缺失或失效、箱数未核平、照片或版本冲突 } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
|
||||
post:
|
||||
summary: 已停用;客户确认完成复核,退回重贴由平台执行
|
||||
|
||||
+10
@@ -672,6 +672,16 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals(4, detail.pallets().get(0).packageCount());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"DISCARD_OUTBOUND", "ONE_CLICK_DROPSHIP"})
|
||||
void discardDoesNotExposeHistoricInboundCodeAsLxButDropshipKeepsRealLx(String type) {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301");
|
||||
outbound.setOutboundType(type); outbound.setLxCodes("LX-REAL-001");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
var detail = service.outboundDetail("OUT-301");
|
||||
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
||||
|
||||
+41
@@ -3,10 +3,51 @@ package com.baiyue.lastleg.discard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class CargoDiscardSchemaContractTest {
|
||||
@Test
|
||||
void returnCartonDeductionAllowsZeroButRejectsNegativeStock() throws Exception {
|
||||
String baseline;
|
||||
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
|
||||
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
var constraint = java.util.regex.Pattern.compile(
|
||||
"CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK\\(([^)]+)\\)")
|
||||
.matcher(baseline);
|
||||
assertTrue(constraint.find());
|
||||
String deduction = ReturnInventoryCartonItemMapper.class.getMethod("deduct", long.class, long.class)
|
||||
.getAnnotation(Update.class).value()[0]
|
||||
.replace("#{quantity}", "?").replace("#{id}", "?");
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:discard_zero_quantity;MODE=MySQL")) {
|
||||
try (var statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE ll_return_inventory_carton_item (id BIGINT PRIMARY KEY, "
|
||||
+ "quantity BIGINT NOT NULL, CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK("
|
||||
+ constraint.group(1) + "))");
|
||||
statement.execute("INSERT INTO ll_return_inventory_carton_item VALUES (1, 3)");
|
||||
try (var update = connection.prepareStatement(deduction)) {
|
||||
update.setLong(1, 3); update.setLong(2, 1); update.setLong(3, 3);
|
||||
assertEquals(1, update.executeUpdate());
|
||||
update.setLong(1, 1); update.setLong(3, 1);
|
||||
assertEquals(0, update.executeUpdate());
|
||||
}
|
||||
try (var result = statement.executeQuery("SELECT quantity FROM ll_return_inventory_carton_item WHERE id=1")) {
|
||||
assertTrue(result.next());
|
||||
assertEquals(0, result.getLong(1));
|
||||
}
|
||||
assertThrows(SQLException.class, () -> statement.executeUpdate(
|
||||
"UPDATE ll_return_inventory_carton_item SET quantity=-1 WHERE id=1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception {
|
||||
String sql;
|
||||
|
||||
+16
@@ -15,6 +15,22 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MerchantCargoDiscardControllerTest {
|
||||
@Test
|
||||
void completionUsesAuthenticatedCustomerAndIndependentPermission() throws Exception {
|
||||
controller.confirmCompletion(user(100L, "MERCHANT"), "CD001");
|
||||
verify(service).confirmCompletion("100", "CD001", "1");
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> controller.confirmCompletion(user(0L, "PLATFORM"), "CD001"));
|
||||
var guard = MerchantCargoDiscardController.class.getMethod("confirmCompletion", AuthenticatedUser.class, String.class)
|
||||
.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(
|
||||
"hasAuthority('merchant:overseas-warehouse:discard:confirm')", guard.value());
|
||||
org.junit.jupiter.api.Assertions.assertFalse(java.util.Arrays.stream(CargoDiscardController.class.getDeclaredMethods())
|
||||
.filter(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class))
|
||||
.flatMap(method -> java.util.Arrays.stream(method.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()))
|
||||
.anyMatch(path -> path.endsWith("/complete") || path.endsWith("/confirm-completion")));
|
||||
}
|
||||
|
||||
private final CargoDiscardService service = mock(CargoDiscardService.class);
|
||||
private final MerchantCargoDiscardController controller = controller();
|
||||
private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query(
|
||||
|
||||
+56
-2
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
|
||||
|
||||
@BeforeEach void setup() {
|
||||
service = new PdaCargoDiscardService();
|
||||
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
|
||||
ReflectionTestUtils.setField(service, "photoService", photoService);
|
||||
ReflectionTestUtils.setField(service, "applications", applications);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
@@ -122,8 +123,9 @@ class PdaCargoDiscardServiceTest {
|
||||
@Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
|
||||
@Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
|
||||
|
||||
@ParameterizedTest @ValueSource(ints = {3, 1, 0})
|
||||
void threeLocalOrdersUploadConfirmAndReplayWithFullPartialAndZeroActualDisposal(int actual) {
|
||||
@Test
|
||||
void fullDisposalUploadsConfirmsAndReplaysWithoutDoubleDeduction() {
|
||||
int actual = 3;
|
||||
var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService();
|
||||
var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.class);
|
||||
var photoFiles = mock(com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.class);
|
||||
@@ -151,4 +153,56 @@ class PdaCargoDiscardServiceTest {
|
||||
verify(photoMapper, times(1)).updateById(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class));
|
||||
assertThrows(ResponseStatusException.class, () -> service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1})));
|
||||
}
|
||||
@ParameterizedTest @ValueSource(ints = {0, 1, 2})
|
||||
void partialAndZeroDisposalDoNotWriteInventoryOrPhotos(int actual) {
|
||||
item.setDiscardPackages(3);
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual)));
|
||||
noWrites(); verifyNoInteractions(photoService);
|
||||
}
|
||||
private void looseTask() {
|
||||
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
|
||||
ReflectionTestUtils.setField(service, "stockService", stockService);
|
||||
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2);
|
||||
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
|
||||
position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99");
|
||||
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
|
||||
when(stockService.cartonMarks(item)).thenReturn(List.of("BOX-1", "BOX-2"));
|
||||
}
|
||||
private PdaCargoDiscardDtos.Confirm looseRequest(List<String> marks, boolean bulk) {
|
||||
return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk);
|
||||
}
|
||||
@Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() {
|
||||
looseTask(); when(applications.selectList(any())).thenReturn(List.of(application));
|
||||
var task = service.resolve(identity, "BOX-1", "CD-1");
|
||||
assertEquals("CARTON", task.scanMode()); assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
|
||||
for (var marks : List.of(List.of("BOX-1"), List.of("BOX-1", "BOX-1"), List.of("BOX-1", "OTHER")))
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(marks, false)));
|
||||
noWrites(); verifyNoInteractions(photoService);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(booleans = {false, true})
|
||||
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) {
|
||||
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk);
|
||||
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
|
||||
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
|
||||
service.confirm(identity, "CD-1", "散箱", request);
|
||||
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
|
||||
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
|
||||
}
|
||||
|
||||
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
|
||||
looseTask(); item.setCartonScanRequired(true);
|
||||
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
|
||||
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
|
||||
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
|
||||
when(applications.selectList(any())).thenReturn(List.of(application));
|
||||
var task = service.resolve(identity, "P-42", "CD-1");
|
||||
assertEquals("CARTON", task.scanMode()); assertEquals("P-42", task.palletNo());
|
||||
assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
|
||||
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1"), false)));
|
||||
verifyNoInteractions(adjustments);
|
||||
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
|
||||
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
|
||||
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.lastleg.discard.service;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.discard.entity.*;
|
||||
import com.baiyue.lastleg.discard.mapper.*;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
|
||||
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class CargoDiscardOutboundServiceTest {
|
||||
private final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
|
||||
private final OutboundPalletItemMapper pallets = mock(OutboundPalletItemMapper.class);
|
||||
private final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
|
||||
private final CargoDiscardCartonMapper cartons = mock(CargoDiscardCartonMapper.class);
|
||||
private final DropshipOrderProfileMapper profiles = mock(DropshipOrderProfileMapper.class);
|
||||
private final ReturnInventoryCartonMapper returnCartons = mock(ReturnInventoryCartonMapper.class);
|
||||
private CargoDiscardOutboundService service;
|
||||
private CargoDiscardApplication application;
|
||||
private CargoDiscardItem first, second;
|
||||
private OutboundOrder document;
|
||||
private final List<OutboundPalletItem> snapshots = new ArrayList<>();
|
||||
|
||||
@BeforeEach void setup() {
|
||||
service = new CargoDiscardOutboundService();
|
||||
ReflectionTestUtils.setField(service, "outbounds", outbounds);
|
||||
ReflectionTestUtils.setField(service, "pallets", pallets);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "cartons", cartons);
|
||||
ReflectionTestUtils.setField(service, "profiles", profiles);
|
||||
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
|
||||
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
|
||||
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
|
||||
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
|
||||
application.setTotalPackages(5); application.setStatus("APPROVED");
|
||||
application.setInboundCodes("RT-123,RK-456");
|
||||
first = line(11L, 21L, 3); second = line(12L, 22L, 2);
|
||||
when(items.listApplication("C1", 1L)).thenReturn(List.of(first, second));
|
||||
when(cartons.listApplication("C1", 1L)).thenReturn(List.of());
|
||||
for (long id : List.of(21L, 22L)) {
|
||||
var profile = new DropshipOrderProfile(); profile.setId(id); profile.setCustomerId("C1"); profile.setPalletNo("P-" + id);
|
||||
when(profiles.selectById(id)).thenReturn(profile);
|
||||
}
|
||||
when(outbounds.findByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
|
||||
when(outbounds.lockByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
|
||||
when(outbounds.insert(any(OutboundOrder.class))).thenAnswer(i -> { document = i.getArgument(0); return 1; });
|
||||
when(pallets.insert(any(OutboundPalletItem.class))).thenAnswer(i -> { snapshots.add(i.getArgument(0)); return 1; });
|
||||
when(pallets.findByOutbound(eq("C1"), anyLong())).thenAnswer(i -> snapshots);
|
||||
}
|
||||
private CargoDiscardItem line(long id, long profile, int quantity) {
|
||||
var line = new CargoDiscardItem(); line.setId(id); line.setApplicationId(1L); line.setCustomerId("C1");
|
||||
line.setProfileId(profile); line.setDiscardPackages(quantity); line.setSkuCode("SKU-" + id); return line;
|
||||
}
|
||||
@Test void approvalCreatesExactlyOneDocumentAndImmutablePlannedLines() {
|
||||
service.create(application, "OP"); service.create(application, "OP");
|
||||
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
|
||||
assertNull(document.getLxCodes());
|
||||
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
|
||||
assertTrue(service.outboundNo(application).startsWith("OB-"));
|
||||
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
|
||||
verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class));
|
||||
}
|
||||
@Test void partialThenFinalCompletionTracksActualsWithoutChangingPlannedQuantities() {
|
||||
service.create(application, "OP"); first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
|
||||
service.sync(application); assertEquals("PICKING", document.getStatus());
|
||||
assertEquals(2, snapshots.get(0).getPickedPackageCount()); assertEquals(0, snapshots.get(1).getPickedPackageCount());
|
||||
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(0);
|
||||
application.setStatus("PDA_COMPLETED"); application.setTotalPackages(2); service.sync(application);
|
||||
assertEquals("OUTBOUNDED", document.getStatus()); assertEquals(2, document.getPackageCount());
|
||||
assertEquals(3, snapshots.get(0).getPackageCount()); assertEquals("PICKED", snapshots.get(1).getStatus());
|
||||
application.setStatus("COMPLETED"); service.sync(application); assertEquals("OUTBOUNDED", document.getStatus());
|
||||
}
|
||||
@Test void cancellationSynchronizesDocumentAndEveryLine() {
|
||||
service.create(application, "OP"); application.setStatus("CANCELLED"); service.sync(application);
|
||||
assertEquals("CANCELLED", document.getStatus()); assertTrue(snapshots.stream().allMatch(s -> "CANCELLED".equals(s.getStatus())));
|
||||
}
|
||||
@Test void syncClearsHistoricallyMislabelledInboundCodesWithoutChangingSourceSnapshots() {
|
||||
first.setInboundCode("RK-456");
|
||||
service.create(application, "OP"); document.setLxCodes("RT-123,RK-456");
|
||||
service.sync(application);
|
||||
assertNull(document.getLxCodes()); assertEquals("RK-456", snapshots.get(0).getInboundCode());
|
||||
assertEquals("RT-123,RK-456", application.getInboundCodes());
|
||||
}
|
||||
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
|
||||
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
|
||||
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
|
||||
carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31");
|
||||
when(returnCartons.selectById(31L)).thenReturn(carton);
|
||||
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks());
|
||||
assertEquals(3L, snapshots.get(0).getReturnSkuQuantity());
|
||||
}
|
||||
@Test void invalidSourcePreventsAnyOutboundInsert() {
|
||||
when(profiles.selectById(22L)).thenReturn(null);
|
||||
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
|
||||
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
|
||||
}
|
||||
}
|
||||
+76
-3
@@ -47,6 +47,36 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CargoDiscardServiceTest {
|
||||
@Test
|
||||
void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() {
|
||||
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
application.setId(1001L); application.setCustomerId("100"); application.setDiscardNo("CD001");
|
||||
application.setTotalPackages(1); application.setOverseasWarehouseName("测试仓"); application.setStatus(CargoDiscardService.PDA_COMPLETED);
|
||||
storedApplication.set(application);
|
||||
when(applications.lockNo("100", "CD001")).thenReturn(application);
|
||||
|
||||
service.confirmCompletion("100", "CD001", "CUSTOMER");
|
||||
var confirmedAt = application.getCustomerConfirmedAt();
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(confirmedAt);
|
||||
assertEquals(CargoDiscardService.COMPLETED, application.getStatus());
|
||||
assertEquals("客户确认废弃完成", storedHistories.get(0).getRemark());
|
||||
service.confirmCompletion("100", "CD001", "CUSTOMER");
|
||||
assertEquals(confirmedAt, application.getCustomerConfirmedAt());
|
||||
assertEquals(1, storedHistories.size());
|
||||
verify(applications, times(1)).updateById(application);
|
||||
org.mockito.Mockito.verifyNoInteractions(adjustments, inventory, returnCartonItems);
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"PENDING_REVIEW", "APPROVED", "REJECTED", "CANCELLED"})
|
||||
void customerCannotConfirmBeforePdaCompletion(String status) {
|
||||
CargoDiscardApplication application = new CargoDiscardApplication(); application.setStatus(status);
|
||||
when(applications.lockNo("100", "CD001")).thenReturn(application);
|
||||
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> service.confirmCompletion("100", "CD001", "CUSTOMER"));
|
||||
verify(applications, never()).updateById(any(CargoDiscardApplication.class));
|
||||
}
|
||||
|
||||
@Mock CargoDiscardApplicationMapper applications;
|
||||
@Mock CargoDiscardItemMapper items;
|
||||
@Mock CargoDiscardCartonMapper discardCartons;
|
||||
@@ -57,8 +87,10 @@ class CargoDiscardServiceTest {
|
||||
@Mock LastlegOrderStatusHistoryMapper orderHistories;
|
||||
@Mock ReturnInventoryCartonMapper returnCartons;
|
||||
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Mock com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient warehouseDirectory;
|
||||
|
||||
private CargoDiscardService service;
|
||||
private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class);
|
||||
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
|
||||
private final List<CargoDiscardItem> storedItems = new ArrayList<>();
|
||||
private final List<CargoDiscardCarton> storedLinks = new ArrayList<>();
|
||||
@@ -67,6 +99,11 @@ class CargoDiscardServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new CargoDiscardService();
|
||||
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
|
||||
lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(
|
||||
new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE"))));
|
||||
ReflectionTestUtils.setField(service, "outboundService", outboundService);
|
||||
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
|
||||
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
|
||||
ReflectionTestUtils.setField(service, "applications", applications);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
@@ -94,18 +131,20 @@ class CargoDiscardServiceTest {
|
||||
@Test
|
||||
void createReservesExactSkuCartonsWithoutDeductingInventory() {
|
||||
LastlegOrder order = order();
|
||||
order.setOverseasWarehouseId("99"); order.setOverseasWarehouseName(null);
|
||||
DropshipSkuInventory sku = skuInventory();
|
||||
when(orders.selectById(101L)).thenReturn(order);
|
||||
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L);
|
||||
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
|
||||
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
|
||||
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
|
||||
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
|
||||
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(2L);
|
||||
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
|
||||
|
||||
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
|
||||
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 2)), "discard-create-1", "damaged"));
|
||||
|
||||
assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status());
|
||||
assertEquals("洛杉矶仓", storedApplication.get().getOverseasWarehouseName());
|
||||
assertEquals(2, detail.summary().totalPackages());
|
||||
assertEquals(new BigDecimal("10.00"), detail.summary().totalFee());
|
||||
assertEquals(2, storedLinks.size());
|
||||
@@ -113,6 +152,38 @@ class CargoDiscardServiceTest {
|
||||
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test void historicalDiscardDetailResolvesMissingWarehouseNameByOperatingWarehouseId() {
|
||||
CargoDiscardApplication application = pendingApplication();
|
||||
application.setOverseasWarehouseId("99"); application.setOverseasWarehouseName(null);
|
||||
storedApplication.set(application);
|
||||
assertEquals("洛杉矶仓", service.detail("100", application.getDiscardNo()).summary().overseasWarehouseName());
|
||||
verify(applications, never()).updateById(any(CargoDiscardApplication.class));
|
||||
}
|
||||
|
||||
|
||||
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
|
||||
when(orders.selectById(101L)).thenReturn(order());
|
||||
when(inventory.lockSelected("100", 101L, "SKU-1", List.of(2L))).thenReturn(List.of(carton(2L)));
|
||||
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(skuInventory());
|
||||
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
|
||||
when(inventory.availableOrderCartons("100", 101L)).thenReturn(List.of(carton(1L), carton(2L), carton(3L)));
|
||||
when(inventory.changeCartonStatus("100", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
|
||||
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
|
||||
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 1, null, List.of(2L))), "partial-create", "damage"));
|
||||
assertEquals(1, detail.summary().totalPackages()); assertEquals(new BigDecimal("5.00"), detail.summary().totalFee());
|
||||
assertEquals(List.of("C-2"), detail.items().get(0).cartonMarks());
|
||||
assertEquals(1, storedLinks.size()); assertEquals(2L, storedLinks.get(0).getCartonId());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(storedItems.get(0).getCartonScanRequired());
|
||||
verify(inventory, never()).changeCartonStatus(eq("100"), eq(1L), any(), any());
|
||||
verify(inventory, never()).changeCartonStatus(eq("100"), eq(3L), any(), any());
|
||||
}
|
||||
@Test void duplicatePhysicalBoxAcrossSkuLinesIsRejectedBeforeCreation() {
|
||||
when(orders.selectById(101L)).thenReturn(order());
|
||||
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create("100", "7", new CargoDiscardRequests.Create(
|
||||
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-A", 1, null, List.of(2L)),
|
||||
new CargoDiscardRequests.Item(101L, null, "SKU-B", 1, null, List.of(2L))), "duplicate-box", null)));
|
||||
verify(applications, never()).insert(any(CargoDiscardApplication.class));
|
||||
}
|
||||
@Test
|
||||
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
|
||||
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
|
||||
@@ -132,7 +203,7 @@ class CargoDiscardServiceTest {
|
||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||
carton.setId(901L);
|
||||
carton.setCustomerId("100");
|
||||
carton.setOverseasWarehouseId("WH-1");
|
||||
carton.setOverseasWarehouseId("99");
|
||||
carton.setSourceReturnNo("RT-20260928-001");
|
||||
carton.setCartonMark("RC-LAX-001");
|
||||
carton.setStatus("AVAILABLE");
|
||||
@@ -149,6 +220,7 @@ class CargoDiscardServiceTest {
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("RETURN_CARTON", result.get(0).sourceType());
|
||||
assertEquals("洛杉矶仓", result.get(0).overseasWarehouseName());
|
||||
assertEquals(901L, result.get(0).returnCartonId());
|
||||
assertEquals("RC-LAX-001", result.get(0).cartonMark());
|
||||
assertEquals(4, result.get(0).availablePackages());
|
||||
@@ -167,6 +239,7 @@ class CargoDiscardServiceTest {
|
||||
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
|
||||
|
||||
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
|
||||
verify(outboundService).create(application, "9");
|
||||
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
|
||||
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
|
||||
assertEquals("ok", storedHistories.get(storedHistories.size() - 1).getRemark());
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package com.baiyue.lastleg.discard.service;
|
||||
|
||||
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
|
||||
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.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class CargoDiscardStockServiceTest {
|
||||
final CargoDiscardInventoryMapper stock = mock(CargoDiscardInventoryMapper.class);
|
||||
final InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
|
||||
final InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
|
||||
final CargoDiscardCartonMapper links = mock(CargoDiscardCartonMapper.class);
|
||||
final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
|
||||
final InventoryCommandService commands = mock(InventoryCommandService.class);
|
||||
final CargoDiscardStockService service = new CargoDiscardStockService();
|
||||
final InventoryPosition position = new InventoryPosition();
|
||||
final InventoryBalance balance = new InventoryBalance();
|
||||
final CargoDiscardApplication application = new CargoDiscardApplication();
|
||||
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
|
||||
@BeforeEach void setup() {
|
||||
ReflectionTestUtils.setField(service, "stock", stock);
|
||||
ReflectionTestUtils.setField(service, "positions", positions);
|
||||
ReflectionTestUtils.setField(service, "balances", balances);
|
||||
ReflectionTestUtils.setField(service, "commands", commands);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "links", links);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L);
|
||||
position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L);
|
||||
balance.setId(51L); balance.setCustomerId("C1"); balance.setOverseasWarehouseId("99");
|
||||
balance.setInboundCode("WT-WH-1"); balance.setInboundOrderNo("WT-WH-1"); balance.setSkuCode("SKU"); balance.setAvailablePackages(100L);
|
||||
application.setId(11L); application.setDiscardNo("CD-1"); application.setCustomerId("C1"); application.setCreatedBy("OP");
|
||||
when(positions.findTenantPosition("C1", 42L)).thenReturn(position);
|
||||
when(stock.lockGenericPosition("C1", 42L)).thenReturn(position);
|
||||
when(balances.selectById(51L)).thenReturn(balance);
|
||||
}
|
||||
@Test void warehouseTransferStockIsEligibleAndKeepsTrueSourcePosition() {
|
||||
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
|
||||
var candidate = service.candidates("C1", "WT").get(0);
|
||||
assertEquals("INVENTORY", candidate.sourceType()); assertNull(candidate.orderId());
|
||||
assertEquals(42L, candidate.inventoryPositionId()); assertEquals("WT-WH-1", candidate.inboundCode());
|
||||
service.reserve("C1", "OP", application, request(100), LocalDateTime.now());
|
||||
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
|
||||
var line = capture.getValue(); assertEquals(42L, line.getInventoryPositionId()); assertEquals(100, line.getDiscardPackages());
|
||||
var move = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); verify(commands).hold(eq("C1"), eq("OP"), move.capture());
|
||||
assertEquals(100, move.getValue().packages()); assertEquals("CARGO_DISCARD:11:" + line.getId(), move.getValue().sourceEventId());
|
||||
service.release("C1", application, line); verify(commands).release(eq("C1"), eq("OP"), any());
|
||||
service.discard("C1", "OP", application, line, 100); verify(commands).discardHeld(eq("C1"), eq("OP"), any());
|
||||
}
|
||||
@Test void partialForeignSkuAndDropshipBypassAreRejectedBeforeHolding() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(99), LocalDateTime.now()));
|
||||
assertThrows(ResponseStatusException.class, () -> service.validate("C2", request(100)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.validate("C1", new CargoDiscardRequests.Item(null, null, "OTHER", 100, 42L)));
|
||||
when(stock.isDropshipPosition("C1", 42L)).thenReturn(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request(100)));
|
||||
verifyNoInteractions(commands, items);
|
||||
}
|
||||
@Test void looseStockRequiresCompleteRealMarksAndSnapshotsThem() {
|
||||
position.setPalletNo(null); position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
|
||||
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1"));
|
||||
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(2), LocalDateTime.now()));
|
||||
verifyNoInteractions(commands, items);
|
||||
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1", "BOX-2"));
|
||||
service.reserve("C1", "OP", application, request(2), LocalDateTime.now());
|
||||
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
|
||||
assertEquals(List.of("BOX-1", "BOX-2"), service.cartonMarks(capture.getValue()));
|
||||
}
|
||||
private com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton(long id, String mark) {
|
||||
var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow();
|
||||
carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton;
|
||||
}
|
||||
@Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() {
|
||||
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
|
||||
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
|
||||
when(stock.changeGenericCartonStatus("C1", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
|
||||
var request = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L));
|
||||
service.reserve("C1", "OP", application, request, LocalDateTime.now());
|
||||
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
|
||||
var line = capture.getValue(); assertEquals(1, line.getDiscardPackages()); assertTrue(line.getCartonScanRequired());
|
||||
assertEquals(List.of("BOX-2"), service.cartonMarks(line));
|
||||
var boxLink = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(boxLink.capture());
|
||||
assertEquals(2L, boxLink.getValue().getCartonId());
|
||||
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(1L), anyString(), anyString());
|
||||
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(3L), anyString(), anyString());
|
||||
when(links.listApplication("C1", 11L)).thenReturn(List.of(boxLink.getValue()));
|
||||
when(stock.changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1);
|
||||
service.release("C1", application, line);
|
||||
verify(stock).changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE");
|
||||
}
|
||||
@Test void partialBoxSelectionRejectsForeignRepeatedAndMismatchedQuantity() {
|
||||
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
|
||||
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
|
||||
for (var request : List.of(new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(9L)),
|
||||
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L, 1L)),
|
||||
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L)))) {
|
||||
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request));
|
||||
}
|
||||
verifyNoInteractions(commands, items, links);
|
||||
}
|
||||
|
||||
@Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() {
|
||||
position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
|
||||
var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER");
|
||||
var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER");
|
||||
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second));
|
||||
when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1);
|
||||
service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now());
|
||||
var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture());
|
||||
assertEquals("TRANSFER", capture.getValue().getSourceType());
|
||||
when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue()));
|
||||
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1);
|
||||
var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture());
|
||||
service.release("C1", application, line.getValue());
|
||||
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED");
|
||||
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
|
||||
}
|
||||
|
||||
}
|
||||
+29
@@ -594,6 +594,35 @@ class InventoryCommandServiceTest {
|
||||
"return-1", List.of("FILE-1"));
|
||||
}
|
||||
|
||||
|
||||
@Test void discardOnlyConsumesOwnedHoldAndKeepsAvailableStockConserved() {
|
||||
var balance = balance(200L, 7); balance.setHoldPackages(3L);
|
||||
var position = position(201L, 200L, 7); position.setHoldPackages(3L);
|
||||
when(commands.lockPosition("100", 201L)).thenReturn(position);
|
||||
when(commands.lockBalance("100", 200L)).thenReturn(balance);
|
||||
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("CD-1"); hold.setDeltaPackages(3L);
|
||||
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
|
||||
when(commands.palletPackages(300L)).thenReturn(7L);
|
||||
service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", "OB-1", "CARGO_DISCARD:1:2", "DISCARD-1", null));
|
||||
assertEquals(7L, position.getAvailablePackages()); assertEquals(7L, balance.getAvailablePackages());
|
||||
assertEquals(0L, position.getHoldPackages()); assertEquals(0L, balance.getHoldPackages());
|
||||
var capture = ArgumentCaptor.forClass(InventoryLedger.class); verify(ledgers).insert(capture.capture());
|
||||
assertEquals("DISCARD", capture.getValue().getBusinessType()); assertEquals(-3L, capture.getValue().getDeltaPackages());
|
||||
assertEquals("OB-1", capture.getValue().getOutboundOrderNo());
|
||||
verify(warehouseGateway, never()).scrapPallet(any(), org.mockito.ArgumentMatchers.anyLong());
|
||||
}
|
||||
@Test void foreignDiscardHoldCannotConsumeAnotherApplicationsStock() {
|
||||
var balance = balance(200L, 0); balance.setHoldPackages(3L);
|
||||
var position = position(201L, 200L, 0); position.setHoldPackages(3L);
|
||||
when(commands.lockPosition("100", 201L)).thenReturn(position);
|
||||
when(commands.lockBalance("100", 200L)).thenReturn(balance);
|
||||
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("OTHER-CD"); hold.setDeltaPackages(3L);
|
||||
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
|
||||
assertThrows(ResponseStatusException.class, () -> service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", null, "CARGO_DISCARD:1:2", "DISCARD-1", null)));
|
||||
assertEquals(3L, position.getHoldPackages());
|
||||
verify(positions, never()).updateById(any(InventoryPosition.class)); verify(ledgers, never()).insert(any(InventoryLedger.class));
|
||||
}
|
||||
|
||||
private InventoryBalance balance(Long id, long available) {
|
||||
InventoryBalance value = new InventoryBalance();
|
||||
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
|
||||
|
||||
+34
@@ -53,6 +53,7 @@ class PdaRelabelServiceTest {
|
||||
@Mock RelabelApplicationService applicationService;
|
||||
@Mock RelabelPhotoFileGateway photoFiles;
|
||||
@Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
|
||||
@Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
|
||||
PdaRelabelService service;
|
||||
RelabelApplication application;
|
||||
RelabelItem item;
|
||||
@@ -62,6 +63,7 @@ class PdaRelabelServiceTest {
|
||||
void setUp() {
|
||||
service = new PdaRelabelService();
|
||||
ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
|
||||
ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements);
|
||||
ReflectionTestUtils.setField(service, "applications", applications);
|
||||
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
@@ -452,12 +454,44 @@ class PdaRelabelServiceTest {
|
||||
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
|
||||
|
||||
verify(photoFiles).validate("100", 20L, 501L);
|
||||
verify(palletMeasurements).requireMeasured(application);
|
||||
verify(skuConversions).apply(eq(application), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyList(), anyString(), org.mockito.ArgumentMatchers.anyList());
|
||||
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
|
||||
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
|
||||
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOriginalRemainderMeasurementBlocksNormalAndManualCompletionBeforeWrites() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask task = task("PROCESSING", 1);
|
||||
task.setRelabelledPackages(1);
|
||||
persistedFacts.add(fact("RELABELLED"));
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
var measurements = org.mockito.Mockito.spy(new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService());
|
||||
var measuredNew = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
|
||||
200L, "NEW", 1, "2:1:1", java.math.BigDecimal.TEN, java.math.BigDecimal.TEN,
|
||||
java.math.BigDecimal.TEN, java.math.BigDecimal.ONE, new java.math.BigDecimal("0.001"), false);
|
||||
var unmeasuredRemainder = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
|
||||
100L, "OLD", 2, "1:3:2", null, null, null, null, null, false);
|
||||
org.mockito.Mockito.doReturn(List.of(measuredNew, unmeasuredRemainder)).when(measurements).snapshots(application);
|
||||
ReflectionTestUtils.setField(service, "palletMeasurements", measurements);
|
||||
|
||||
for (boolean manual : List.of(false, true)) {
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-" + manual, "done", manual)));
|
||||
assertTrue(error.getReason().contains("托盘 OLD"));
|
||||
assertEquals("PROCESSING", task.getStatus());
|
||||
assertEquals("PROCESSING", application.getStatus());
|
||||
}
|
||||
verify(tasks, never()).updateWithVersion(any(), anyInt());
|
||||
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
|
||||
org.mockito.Mockito.verifyNoInteractions(applicationService, photoFiles, skuConversions);
|
||||
verify(histories, never()).insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class));
|
||||
verify(operations, never()).insert(any(PdaRelabelOperation.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",
|
||||
|
||||
+41
@@ -373,6 +373,47 @@ class ReturnInventoryServiceTest {
|
||||
verify(balances, never()).adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(longs = {3L, 6L})
|
||||
void mergeCoalescesTrackedAndOriginalRemainderOwnedByTheSameReturn(long quantity) {
|
||||
var source = prepareConcurrentSource();
|
||||
source.setTotalQuantity(6L);
|
||||
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L)));
|
||||
when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of(
|
||||
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L)));
|
||||
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
|
||||
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
|
||||
|
||||
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(quantity));
|
||||
|
||||
verify(packingTrace).insert(anyLong(), eq("10001"), anyLong(), eq("RC-SOURCE"),
|
||||
eq("RC-TARGET"), eq("RT-1"), eq("SKU-A"), eq(quantity), any());
|
||||
verify(packingTrace, org.mockito.Mockito.times(1)).insert(anyLong(), anyString(), anyLong(),
|
||||
anyString(), anyString(), anyString(), anyString(), anyLong(), any());
|
||||
assertThat(source.getTotalQuantity()).isEqualTo(6L - quantity);
|
||||
}
|
||||
|
||||
@Test
|
||||
void partialMergeStillRejectsActuallyDifferentReturnOwners() {
|
||||
var source = prepareConcurrentSource();
|
||||
source.setSourceReturnNo("RT-1,RT-2"); source.setTotalQuantity(6L);
|
||||
var otherOrder = completedOrder(); otherOrder.setId(12L); otherOrder.setReturnNo("RT-2");
|
||||
org.mockito.Mockito.reset(orders);
|
||||
when(orders.findByNos("42", List.of("RT-1", "RT-2"))).thenReturn(List.of(completedOrder(), otherOrder));
|
||||
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L)));
|
||||
when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of(
|
||||
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L),
|
||||
new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-2", 3L)));
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
|
||||
new PdaWarehouseIdentity("operator-2", "操作员2", 42L),
|
||||
new ReturnInventoryRequests.Merge(List.of("RT-1", "RT-2"),
|
||||
List.of(new ReturnInventoryRequests.ValidItem("SKU-A", 3L)),
|
||||
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, "mixed-owner-key")));
|
||||
assertThat(error.getReason()).contains("不能自动猜测来源归属");
|
||||
verify(packingTrace, never()).insert(anyLong(), anyString(), anyLong(), anyString(),
|
||||
anyString(), anyString(), anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void partiallyConsumedSourceUsesCurrentLockedSkuQuantityNotEarlierSnapshot() {
|
||||
ReturnInventoryCarton locked = prepareConcurrentSource();
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.baiyue.lastleg.returnreceipt.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.tuple;
|
||||
|
||||
public class ReturnPackingTracePersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private ReturnPackingTraceMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:packing-trace-" + UUID.randomUUID()
|
||||
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '" + getClass().getName() + ".jsonExtract'");
|
||||
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '" + getClass().getName() + ".jsonUnquote'");
|
||||
jdbc.execute("CREATE TABLE ll_return_inventory_operation (id BIGINT PRIMARY KEY, operation_type VARCHAR(40), result_json VARCHAR(1000))");
|
||||
jdbc.execute("CREATE TABLE ll_return_packing_trace (id BIGINT PRIMARY KEY, customer_id VARCHAR(80), operation_id BIGINT, "
|
||||
+ "source_carton_mark VARCHAR(80), target_carton_mark VARCHAR(80), return_no VARCHAR(80), sku_code VARCHAR(80), quantity BIGINT, created_at TIMESTAMP)");
|
||||
var config = new MybatisConfiguration();
|
||||
config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(ReturnPackingTraceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(ds); factory.setConfiguration(config);
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnPackingTraceMapper.class);
|
||||
operation(1, "PLAN");
|
||||
trace(1, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU", 3);
|
||||
trace(2, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU", 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void allDetailQueriesSumHistoricalFragmentsWithoutChangingRawTrace() {
|
||||
trace(3, 1, "C", "SOURCE-B", "TARGET", "RETURN-B", "SKU-B", 15);
|
||||
assertThat(mapper.planHistory("C", "PLAN"))
|
||||
.extracting(row -> row.skuCode(), row -> row.quantity())
|
||||
.containsExactly(tuple("SKU", 6L), tuple("SKU-B", 15L));
|
||||
assertThat(mapper.returnHistory("C", "RETURN")).singleElement()
|
||||
.satisfies(row -> assertThat(row.quantity()).isEqualTo(6));
|
||||
assertThat(mapper.listCartonHistory("C", "TARGET"))
|
||||
.extracting(row -> row.skuCode(), row -> row.quantity())
|
||||
.containsExactly(tuple("SKU-B", 15L), tuple("SKU", 6L));
|
||||
assertThat(mapper.listReturn("C", "RETURN")).hasSize(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_return_packing_trace", Long.class)).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupingKeepsOperationReturnSkuAndCartonBoundariesAndCustomerIsolation() {
|
||||
operation(2, "PLAN"); operation(3, "OTHER-PLAN");
|
||||
trace(3, 2, "C", "SOURCE", "TARGET", "RETURN", "SKU", 2);
|
||||
trace(4, 3, "C", "SOURCE", "TARGET", "RETURN", "SKU", 1);
|
||||
trace(5, 1, "C", "SOURCE", "TARGET", "RETURN-B", "SKU", 4);
|
||||
trace(6, 1, "C", "SOURCE-B", "TARGET", "RETURN", "SKU", 5);
|
||||
trace(7, 1, "C", "SOURCE", "TARGET-B", "RETURN", "SKU", 7);
|
||||
trace(8, 1, "C", "SOURCE", "TARGET", "RETURN", "SKU-B", 8);
|
||||
trace(9, 1, "OTHER-CUSTOMER", "SOURCE", "TARGET", "RETURN", "SKU", 99);
|
||||
trace(10, 1, "C", "", "TARGET", "RETURN", "SKU", 100);
|
||||
|
||||
assertThat(mapper.planHistory("C", "PLAN"))
|
||||
.extracting(row -> row.sourceCartonMark(), row -> row.targetCartonMark(), row -> row.returnNo(), row -> row.skuCode(), row -> row.quantity())
|
||||
.containsExactly(tuple("SOURCE", "TARGET", "RETURN", "SKU", 6L),
|
||||
tuple("SOURCE", "TARGET", "RETURN", "SKU", 2L),
|
||||
tuple("SOURCE", "TARGET", "RETURN-B", "SKU", 4L),
|
||||
tuple("SOURCE-B", "TARGET", "RETURN", "SKU", 5L),
|
||||
tuple("SOURCE", "TARGET-B", "RETURN", "SKU", 7L),
|
||||
tuple("SOURCE", "TARGET", "RETURN", "SKU-B", 8L));
|
||||
assertThat(mapper.returnHistory("C", "RETURN")).extracting(row -> row.quantity())
|
||||
.containsExactly(6L, 2L, 1L, 5L, 7L, 8L);
|
||||
assertThat(mapper.listCartonHistory("C", "TARGET")).extracting(row -> row.quantity())
|
||||
.containsExactly(8L, 5L, 4L, 1L, 2L, 6L);
|
||||
assertThat(mapper.planHistory("MISSING", "PLAN")).isEmpty();
|
||||
assertThat(mapper.returnHistory("MISSING", "RETURN")).isEmpty();
|
||||
assertThat(mapper.listCartonHistory("MISSING", "TARGET")).isEmpty();
|
||||
}
|
||||
|
||||
private void operation(long id, String plan) {
|
||||
jdbc.update("INSERT INTO ll_return_inventory_operation VALUES(?, 'MERGE', ?)", id, "{\"planNo\":\"" + plan + "\"}");
|
||||
}
|
||||
|
||||
private void trace(long id, long operation, String customer, String source, String target, String owner, String sku, long quantity) {
|
||||
jdbc.update("INSERT INTO ll_return_packing_trace VALUES(?,?,?,?,?,?,?,?,TIMESTAMP '2026-10-15 16:13:36.447')",
|
||||
id, customer, operation, source, target, owner, sku, quantity);
|
||||
}
|
||||
|
||||
public static String jsonExtract(String json, String path) throws Exception {
|
||||
return new ObjectMapper().readTree(json).path(path.substring(2)).asText(null);
|
||||
}
|
||||
|
||||
public static String jsonUnquote(String value) { return value; }
|
||||
}
|
||||
+10
@@ -18,6 +18,16 @@ class WarehouseTransferCargoValidationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skuUnitsAndExpectedCartonsAreIndependent() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
assertTrue(validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 120L)).isEmpty());
|
||||
assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 3, null, 0L)).isEmpty());
|
||||
assertTrue(!validator.validate(new WarehouseTransferRequests.Item("SKU-A", "货物", 0, null, 120L)).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cartonRelabelAllowsOriginalSkuToBeOmitted() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import com.baiyue.lastleg.transfer.entity.*;
|
||||
import com.baiyue.lastleg.transfer.mapper.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class WarehouseTransferCartonCountTest {
|
||||
WarehouseTransferService service;
|
||||
WarehouseTransferOrderMapper orders = mock(WarehouseTransferOrderMapper.class);
|
||||
WarehouseTransferCartonMapper cartons = mock(WarehouseTransferCartonMapper.class);
|
||||
WarehouseTransferItemMapper items = mock(WarehouseTransferItemMapper.class);
|
||||
WarehouseTransferPalletMapper palletMapper = mock(WarehouseTransferPalletMapper.class);
|
||||
WarehouseTransferPallet pallet;
|
||||
WarehouseTransferOrder order;
|
||||
WarehouseTransferCarton a, b;
|
||||
@BeforeEach void setup() {
|
||||
service = spy(new WarehouseTransferService());
|
||||
ReflectionTestUtils.setField(service, "transfers", orders);
|
||||
ReflectionTestUtils.setField(service, "transferCartons", cartons);
|
||||
ReflectionTestUtils.setField(service, "transferItems", items);
|
||||
ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
|
||||
pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setStatus("GENERATED"); pallet.setWarehousePalletId(900L);
|
||||
when(palletMapper.listOrder("C1", 1L)).thenReturn(List.of(pallet));
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
doReturn(null).when(service).detail(anyString(), anyString());
|
||||
order = new WarehouseTransferOrder(); order.setId(1L); order.setCustomerId("C1"); order.setTransferNo("WT1"); order.setStatus("WAIT_PDA_RECEIVING"); order.setPackageCount(2);
|
||||
a = box("BOX1", 10L); b = box("BOX2", 11L);
|
||||
when(orders.lockForPda("WT1", "77")).thenReturn(order);
|
||||
when(cartons.listOrder("C1", 1L)).thenReturn(List.of(a,b));
|
||||
var ia = new WarehouseTransferItem(); ia.setId(10L); ia.setSkuCode("A");
|
||||
var ib = new WarehouseTransferItem(); ib.setId(11L); ib.setSkuCode("B");
|
||||
when(items.selectList(any())).thenReturn(List.of(ia,ib));
|
||||
}
|
||||
WarehouseTransferCarton box(String mark, long item) { var box = new WarehouseTransferCarton(); box.setCartonMark(mark); box.setCartonIndex((int)item); box.setTransferItemId(item); return box; }
|
||||
WarehouseTransferRequests.CountCarton input(String mark, String sku, long quantity) { return new WarehouseTransferRequests.CountCarton(mark, null, 200L, new BigDecimal("20"),new BigDecimal("30"),new BigDecimal("40"),new BigDecimal("2.5"),sku,quantity); }
|
||||
WarehouseTransferRequests.CountCartons request(String key, WarehouseTransferRequests.CountCarton... rows) { return new WarehouseTransferRequests.CountCartons(key,List.of(rows),null); }
|
||||
@Test void multiSkuBoxesPersistDimensionsPiecesAndReplayWithoutWriting() {
|
||||
var request = request("K1",input("BOX1","A",12),input("BOX2","B",7));
|
||||
service.countCartons("OP1",77L,"WT1",request);
|
||||
assertEquals(12L,a.getCountedQuantity()); assertEquals(7L,b.getCountedQuantity()); assertNotNull(a.getCountedAt()); assertEquals("OP1",a.getCountedBy()); assertEquals(new BigDecimal("2.5"),b.getGrossWeightKg());
|
||||
assertEquals(200L,a.getTransferPalletId()); assertEquals(2,pallet.getCartonCount());
|
||||
assertEquals(new BigDecimal("0.048000"),pallet.getVolumeCbm()); assertEquals(new BigDecimal("5.0"),pallet.getGrossWeightKg());
|
||||
clearInvocations(cartons); order.setStatus("WAIT_CUSTOMER_CONFIRMATION");
|
||||
service.countCartons("OP1",77L,"WT1",request);
|
||||
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K1",input("BOX1","A",13),input("BOX2","B",7))));
|
||||
}
|
||||
@Test void missingDuplicateForeignSkuAndInvalidQuantityRejectBeforeWrites() {
|
||||
var invalid = List.of(request("K",input("BOX1","A",1)),request("K",input("BOX1","A",1),input("BOX1","A",2)),request("K",input("OTHER","A",1),input("BOX2","B",2)),request("K",input("BOX1","B",1),input("BOX2","B",2)),request("K",input("BOX1","A",0),input("BOX2","B",2)));
|
||||
for(var request:invalid) assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request));
|
||||
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||
}
|
||||
@Test void shortReceiptNeedsReasonAndPreservesMissingBoxFact() {
|
||||
var request = new WarehouseTransferRequests.CountCartons("SHORT",List.of(input("BOX1","A",3)),"少到一箱");
|
||||
service.countCartons("OP1",77L,"WT1",request);
|
||||
assertEquals("COUNTED",a.getStatus()); assertEquals("NOT_RECEIVED",b.getStatus());
|
||||
assertEquals("少到一箱",b.getDiscrepancyReason()); assertNull(b.getCountedQuantity());
|
||||
}
|
||||
@Test void extraReceiptGeneratesOwnedBoxAndReconcilesToDeclaredSku() {
|
||||
var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,"B",4);
|
||||
service.countCartons("OP1",77L,"WT1",new WarehouseTransferRequests.CountCartons("EXTRA",List.of(input("BOX1","A",1),input("BOX2","B",2),extra),"多到一箱"));
|
||||
var captured = org.mockito.ArgumentCaptor.forClass(WarehouseTransferCarton.class);
|
||||
verify(cartons).insert(captured.capture());
|
||||
assertEquals("WT1#EX-12",captured.getValue().getCartonMark()); assertEquals("C1",captured.getValue().getCustomerId()); assertEquals(4L,captured.getValue().getCountedQuantity());
|
||||
}
|
||||
@Test void cartonsRequireGeneratedPalletOwnedByThisOrder() {
|
||||
when(palletMapper.listOrder("C1",1L)).thenReturn(List.of());
|
||||
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2))));
|
||||
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||
when(palletMapper.listOrder("C1",1L)).thenReturn(List.of(pallet)); pallet.setStatus("DRAFT");
|
||||
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2))));
|
||||
}
|
||||
@Test void multiSkuPalletCreatesOneInboundLineAllocationWithSummedBoxes() {
|
||||
var plans = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper.class);
|
||||
var lines = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper.class);
|
||||
var pallets = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper.class);
|
||||
var allocations = mock(com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper.class);
|
||||
ReflectionTestUtils.setField(service,"inboundPlans",plans); ReflectionTestUtils.setField(service,"inboundPlanLines",lines);
|
||||
ReflectionTestUtils.setField(service,"inboundPlanPallets",pallets); ReflectionTestUtils.setField(service,"inboundPlanAllocations",allocations);
|
||||
order.setLastlegOrderId(9L); order.setInboundCode("WT1");
|
||||
var plan = new com.baiyue.lastleg.inboundplan.entity.InboundPlan(); plan.setId(100L);
|
||||
var line = new com.baiyue.lastleg.inboundplan.entity.InboundPlanLine(); line.setId(101L);
|
||||
when(plans.findBySourceOrder("C1",9L)).thenReturn(plan); when(lines.selectOne(any())).thenReturn(line);
|
||||
var pallet = new WarehouseTransferPallet(); pallet.setId(200L); pallet.setCartonCount(3); pallet.setVolumeCbm(BigDecimal.ONE); pallet.setSequenceNo(1);
|
||||
var first = new WarehouseTransferPalletItem(); first.setTransferPalletId(200L); first.setTransferItemId(10L); first.setPackageCount(1);
|
||||
var second = new WarehouseTransferPalletItem(); second.setTransferPalletId(200L); second.setTransferItemId(11L); second.setPackageCount(2);
|
||||
ReflectionTestUtils.invokeMethod(service,"completeTransferInboundPlan","C1",order,List.of(pallet),List.of(first,second),java.time.LocalDateTime.now());
|
||||
var result=org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class);
|
||||
verify(allocations,times(1)).insert(result.capture()); assertEquals(3,result.getValue().getPickedPackageCount()); assertEquals(3,result.getValue().getCountedPackageCount());
|
||||
}
|
||||
@Test void otherWarehouseCannotReadOrWriteAndCompletedCountsCannotBeOverwritten() {
|
||||
assertThrows(ResponseStatusException.class,()->service.countCartons("OP1",88L,"WT1",request("K",input("BOX1","A",1))));
|
||||
service.countCartons("OP1",77L,"WT1",request("K",input("BOX1","A",1),input("BOX2","B",2)));
|
||||
clearInvocations(cartons);
|
||||
assertThrows(ResponseStatusException.class,()->service.countCartons("OP2",77L,"WT1",request("NEW",input("BOX1","A",9),input("BOX2","B",2))));
|
||||
verify(cartons,never()).updateById(any(WarehouseTransferCarton.class));
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -48,19 +48,17 @@ class WarehouseTransferPalletAllocationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistedOverReceiptUsesTheSameStableAllocationRule() {
|
||||
WarehouseTransferPallet first = persistedPallet(4);
|
||||
WarehouseTransferPallet second = persistedPallet(3);
|
||||
|
||||
var allocations = WarehouseTransferService.derivePersistedPalletAllocations(
|
||||
List.of(item(11L, 3), item(12L, 2)), List.of(first, second));
|
||||
|
||||
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
|
||||
assertEquals(3, allocations.stream().filter(value -> value.transferItemId() == 11L)
|
||||
.mapToInt(value -> value.packageCount()).sum());
|
||||
assertEquals(4, allocations.stream().filter(value -> value.transferItemId() == 12L)
|
||||
.mapToInt(value -> value.packageCount()).sum());
|
||||
assertEquals(List.of(0, 0, 1), allocations.stream().map(value -> value.palletIndex()).toList());
|
||||
void countedBoxesDetermineSkuPlacementRatherThanDeclarationOrder() {
|
||||
WarehouseTransferPallet first = persistedPallet(2); first.setId(100L);
|
||||
WarehouseTransferPallet second = persistedPallet(1); second.setId(200L);
|
||||
var a = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); a.setTransferItemId(12L); a.setTransferPalletId(100L);
|
||||
var b = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); b.setTransferItemId(12L); b.setTransferPalletId(100L);
|
||||
var c = new com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton(); c.setTransferItemId(11L); c.setTransferPalletId(200L);
|
||||
var allocations = WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second));
|
||||
assertEquals(List.of(new WarehouseTransferService.DerivedPalletAllocation(0,12L,2),new WarehouseTransferService.DerivedPalletAllocation(1,11L,1)),allocations);
|
||||
c.setTransferPalletId(999L);
|
||||
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> WarehouseTransferService.deriveCountedPalletAllocations(List.of(a,b,c),List.of(first,second)));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+8
@@ -322,6 +322,10 @@ class WarehouseTransferServiceTest {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||
transfer.setOperatingWarehouseId("77");
|
||||
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
|
||||
var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> {
|
||||
var box = new WarehouseTransferCarton(); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box;
|
||||
}).toList();
|
||||
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted);
|
||||
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
|
||||
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
|
||||
@@ -366,6 +370,10 @@ class WarehouseTransferServiceTest {
|
||||
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var counted = java.util.stream.IntStream.rangeClosed(1, 5).mapToObj(index -> {
|
||||
var box = new WarehouseTransferCarton(); box.setTransferPalletId(pallet.getId()); box.setTransferItemId(item.getId()); box.setCartonIndex(index); box.setTotalCartons(5); box.setCartonMark("BOX-" + index); box.setStatus("COUNTED"); box.setCountedAt(LocalDateTime.now()); return box;
|
||||
}).toList();
|
||||
when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(counted);
|
||||
var result = service.palletize("OP-1", 77L, "WTR-1",
|
||||
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user