This commit is contained in:
2026-10-15 15:42:10 +08:00
parent 095a95e64a
commit c6bd6af5ce
43 changed files with 1153 additions and 92 deletions
@@ -1426,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())
@@ -1611,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(),
@@ -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) {
@@ -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,
@@ -34,7 +34,7 @@ public final class CargoDiscardResponses {
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 outboundNo) {}
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, List<String> cartonMarks) {
@@ -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;
@@ -122,7 +122,7 @@ 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()))
@@ -50,7 +50,7 @@ public class CargoDiscardOutboundService {
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(application.getInboundCodes()); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo());
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);
@@ -116,6 +116,7 @@ public class CargoDiscardOutboundService {
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);
}
@@ -76,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");
@@ -105,7 +107,14 @@ public class CargoDiscardService {
carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
null, available, skus));
}
return result.stream().map(candidate -> withBoxes(customerId, candidate)).toList();
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) {
@@ -201,6 +210,7 @@ public class CargoDiscardService {
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()));
@@ -258,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());
}
@@ -325,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);
}
@@ -478,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(), outboundService.outboundNo(value));
value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -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";
@@ -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,
@@ -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,
@@ -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) {}
@@ -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;
}
@@ -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;
}
@@ -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();
@@ -3069,7 +3069,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 (
@@ -4005,6 +4005,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 '应用生成的流水记录标识',
@@ -26,9 +26,27 @@ info:
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'
@@ -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' }