贴标申请,仓储转运
This commit is contained in:
+21
@@ -70,6 +70,27 @@ public class DeliveryTransferController {
|
||||
return ApiResponse.success(service.createReturnOutbound(requiredCustomer(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders/warehouse-transfer/candidates")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "List warehouse-transfer pallets that can create an outbound order")
|
||||
public ApiResponse<java.util.List<DeliveryResponses.WarehouseTransferOutboundCandidate>> warehouseTransferCandidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@RequestParam(required = false) String warehouseId) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.warehouseTransferOutboundCandidates(customerId, warehouseId));
|
||||
}
|
||||
|
||||
@PostMapping("/outbound-orders/warehouse-transfer")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
@Operation(summary = "Create a warehouse-transfer outbound, delivery order and PDA task")
|
||||
public ApiResponse<DeliveryResponses.OutboundDetail> createWarehouseTransferOutbound(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
|
||||
@Valid @RequestBody DeliveryRequests.WarehouseTransferOutbound request) {
|
||||
return ApiResponse.success(service.createWarehouseTransferOutbound(
|
||||
requiredCustomer(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/outbound-orders")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')")
|
||||
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
|
||||
|
||||
@@ -75,6 +75,20 @@ public final class DeliveryRequests {
|
||||
@NotNull @Size(min=1, max=200) List<@NotBlank @Size(max=128) String> cartonMarks,
|
||||
@Size(max=1000) String remark,
|
||||
@NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record WarehouseTransferOutbound(
|
||||
@NotBlank @Size(max=64) String transferNo,
|
||||
@NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds,
|
||||
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType,
|
||||
@Size(max=128) String destinationWarehouseId,
|
||||
@Size(max=128) String destinationWarehouseCode,
|
||||
@Size(max=255) String destinationWarehouseName,
|
||||
@Size(max=1000) String deliveryAddress,
|
||||
@Size(max=128) String contactName,
|
||||
@Size(max=64) String contactPhone,
|
||||
@Size(max=32) String deliveryMethod,
|
||||
LocalDateTime plannedDeliveryAt,
|
||||
@Size(max=1000) String remark,
|
||||
@NotBlank @Size(max=255) String idempotencyKey) {}
|
||||
public record OneClickItem(@NotNull Long inventoryPositionId, @NotNull @Min(1) Long packages,
|
||||
@NotBlank @Size(max=128) String sku,
|
||||
@Size(max=200) List<@NotBlank @Size(max=128) String> cartonMarks) {
|
||||
|
||||
+12
-1
@@ -31,7 +31,8 @@ public final class DeliveryResponses {
|
||||
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
|
||||
String bolNo, DeliveryDocumentDtos.Summary documentSummary,
|
||||
String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName) {}
|
||||
String destinationWarehouseName, String destinationType,
|
||||
String deliveryAddress, String contactName, String contactPhone, String postalCode) {}
|
||||
|
||||
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
|
||||
String receivingAddress, String shipmentNo, String fbaTrackingNo,
|
||||
@@ -59,6 +60,16 @@ public final class DeliveryResponses {
|
||||
String cartonMark, String fbaCartonMark,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
long totalQuantity, String status) {}
|
||||
public record WarehouseTransferOutboundCandidate(String customerId, String customerName,
|
||||
String transferNo, String overseasWarehouseId,
|
||||
String transferStatus, boolean needRelabel,
|
||||
String relabelNo, String destinationType,
|
||||
String destinationWarehouseCode,
|
||||
String privateDestinationAddress,
|
||||
String privateDestinationPostalCode,
|
||||
Long palletId, Long warehousePalletId, String palletNo,
|
||||
int packageCount, BigDecimal grossWeightKg,
|
||||
BigDecimal volumeCbm) {}
|
||||
public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {}
|
||||
public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {}
|
||||
public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark,
|
||||
|
||||
@@ -26,6 +26,11 @@ public class DeliveryOrder {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private String deliveryGroupKey;
|
||||
private String status;
|
||||
private String waybillNo;
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ public class DeliveryCompletedOutboxService {
|
||||
Objects.requireNonNull(completedAt, "completedAt");
|
||||
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
|
||||
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
|
||||
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
|
||||
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
|
||||
requireText(order.getSourceOrderId(), "sourceOrderId");
|
||||
requireText(order.getInboundCode(), "inboundCode");
|
||||
|
||||
+240
-4
@@ -60,7 +60,9 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -97,6 +99,7 @@ public class DeliveryTransferService {
|
||||
@Resource private InventoryBalanceMapper inventoryBalances;
|
||||
@Resource private InventoryCommandService inventoryCommands;
|
||||
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
|
||||
@Resource private WarehouseTransferOrderMapper warehouseTransfers;
|
||||
@Resource private DropshipOrderProfileMapper dropshipProfiles;
|
||||
@Resource private DropshipCartonMapper dropshipCartons;
|
||||
@Resource private DropshipCartonSkuMapper dropshipCartonSkus;
|
||||
@@ -217,6 +220,11 @@ public class DeliveryTransferService {
|
||||
firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()),
|
||||
destination.getDestinationWarehouseId()));
|
||||
delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName()));
|
||||
delivery.setDestinationType(destination.getDestinationType());
|
||||
delivery.setDeliveryAddress(destination.getDeliveryAddress());
|
||||
delivery.setPostalCode(destination.getPostalCode());
|
||||
delivery.setContactName(destination.getContactName());
|
||||
delivery.setContactPhone(destination.getContactPhone());
|
||||
delivery.setDeliveryGroupKey(entry.getKey());
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, groupItems);
|
||||
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
@@ -395,6 +403,155 @@ public class DeliveryTransferService {
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<DeliveryResponses.WarehouseTransferOutboundCandidate> warehouseTransferOutboundCandidates(
|
||||
String customerId, String warehouseId) {
|
||||
List<WarehouseTransferOrder> transfers = warehouseTransfers.selectList(
|
||||
new LambdaQueryWrapper<WarehouseTransferOrder>()
|
||||
.eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, trim(customerId))
|
||||
.eq(StringUtils.hasText(warehouseId), WarehouseTransferOrder::getOperatingWarehouseId, trim(warehouseId))
|
||||
.eq(WarehouseTransferOrder::getStatus, "WAIT_FBA_DESTINATION")
|
||||
.orderByDesc(WarehouseTransferOrder::getUpdatedAt));
|
||||
List<DeliveryResponses.WarehouseTransferOutboundCandidate> result = new ArrayList<>();
|
||||
for (WarehouseTransferOrder transfer : transfers) {
|
||||
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
List<WarehouseTransferPallet> completedPallets = warehouseTransferPallets.listOrder(
|
||||
transfer.getCustomerId(), transfer.getId()).stream()
|
||||
.filter(pallet -> "COMPLETED".equals(pallet.getStatus()) && pallet.getWarehousePalletId() != null)
|
||||
.toList();
|
||||
long available = 0;
|
||||
for (WarehouseTransferPallet pallet : completedPallets) {
|
||||
long palletAvailable = inventoryPositions.listPallet(transfer.getCustomerId(), pallet.getWarehousePalletId())
|
||||
.stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId()))
|
||||
.mapToLong(position -> value(position.getAvailablePackages())).sum();
|
||||
available += palletAvailable;
|
||||
}
|
||||
if (available <= 0 || completedPallets.isEmpty()) continue;
|
||||
result.add(new DeliveryResponses.WarehouseTransferOutboundCandidate(
|
||||
transfer.getCustomerId(), order == null ? null : order.getCustomerName(), transfer.getTransferNo(),
|
||||
transfer.getOperatingWarehouseId(), transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()),
|
||||
transfer.getRelabelNo(), transfer.getDestinationType(), transfer.getDestinationWarehouseCode(),
|
||||
transfer.getPrivateDestinationAddress(), transfer.getPrivateDestinationPostalCode(),
|
||||
null, null, null, Math.toIntExact(available),
|
||||
completedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg).filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add),
|
||||
completedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm).filter(Objects::nonNull)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId,
|
||||
DeliveryRequests.WarehouseTransferOutbound request) {
|
||||
String key = request.idempotencyKey().trim();
|
||||
String requestHash = hash(request);
|
||||
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, key);
|
||||
if (replay != null) {
|
||||
if (!"WAREHOUSE_TRANSFER_OUTBOUND".equals(replay.getOutboundType())
|
||||
|| !requestHash.equals(replay.getRequestHash())) {
|
||||
throw conflict("相同幂等键不能用于不同的仓储转运出库请求");
|
||||
}
|
||||
return outboundDetail(replay.getOutboundNo());
|
||||
}
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, request.transferNo().trim());
|
||||
if (transfer == null) throw notFound("仓储转运单不存在");
|
||||
if (!Set.of("READY_FOR_DISPATCH", "WAIT_FBA_DESTINATION").contains(transfer.getStatus())) {
|
||||
throw conflict("仓储转运单尚未完成换标或已经生成出库单");
|
||||
}
|
||||
String destinationType = request.destinationType().trim().toUpperCase(Locale.ROOT);
|
||||
if ("FBA".equals(destinationType) && !StringUtils.hasText(request.destinationWarehouseCode())) {
|
||||
throw conflict("FBA出库必须选择目的仓");
|
||||
}
|
||||
if ("PRIVATE".equals(destinationType) && (!StringUtils.hasText(request.deliveryAddress())
|
||||
|| !StringUtils.hasText(request.contactName()) || !StringUtils.hasText(request.contactPhone()))) {
|
||||
throw conflict("私人仓出库必须填写地址、联系人和联系电话");
|
||||
}
|
||||
List<Long> palletIds = request.palletIds().stream().distinct().toList();
|
||||
if (palletIds.size() != request.palletIds().size()) throw conflict("出库托盘不可重复");
|
||||
List<WarehouseTransferPallet> selectedPallets = new ArrayList<>();
|
||||
for (Long palletId : palletIds) {
|
||||
WarehouseTransferPallet pallet = warehouseTransferPallets.lockOne(customerId, transfer.getId(), palletId);
|
||||
if (pallet == null || !"COMPLETED".equals(pallet.getStatus()) || pallet.getWarehousePalletId() == null) {
|
||||
throw conflict("所选托盘不属于该仓储转运单或尚未完成入库:" + palletId);
|
||||
}
|
||||
selectedPallets.add(pallet);
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||
outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId()));
|
||||
outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
||||
outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK");
|
||||
outbound.setRecipientInfo("PRIVATE".equals(destinationType)
|
||||
? request.contactName().trim() + " / " + request.contactPhone().trim() + " / " + request.deliveryAddress().trim()
|
||||
: request.destinationWarehouseCode().trim());
|
||||
outbound.setPalletCount(selectedPallets.size()); outbound.setIdempotencyKey(key);
|
||||
outbound.setRequestHash(requestHash); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||
outboundOrders.insert(outbound);
|
||||
|
||||
int packageCount = 0;
|
||||
for (WarehouseTransferPallet pallet : selectedPallets) {
|
||||
List<InventoryPosition> positions = inventoryPositions.listPallet(customerId, pallet.getWarehousePalletId())
|
||||
.stream().filter(position -> transfer.getOperatingWarehouseId().equals(position.getOverseasWarehouseId()))
|
||||
.filter(position -> value(position.getAvailablePackages()) > 0).toList();
|
||||
if (positions.isEmpty()) throw conflict("所选托盘没有可出库库存:" + pallet.getPalletNo());
|
||||
for (InventoryPosition position : positions) {
|
||||
InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId());
|
||||
if (balance == null || !customerId.equals(balance.getCustomerId())) throw conflict("托盘库存余额不存在");
|
||||
int packages = Math.toIntExact(value(position.getAvailablePackages()));
|
||||
OutboundPalletItem item = new OutboundPalletItem();
|
||||
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
|
||||
item.setWarehousePalletId(pallet.getWarehousePalletId()); item.setPalletNo(pallet.getPalletNo());
|
||||
item.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
|
||||
item.setDestinationWarehouseCode(trim(request.destinationWarehouseCode()));
|
||||
item.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
|
||||
item.setPackageCount(packages); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
item.setSkuCode(balance.getSkuCode()); item.setInboundCode(balance.getInboundCode());
|
||||
item.setOrderRefs(transfer.getTransferNo()); item.setCreatedAt(now); outboundPallets.insert(item);
|
||||
packageCount = Math.addExact(packageCount, packages);
|
||||
}
|
||||
}
|
||||
outbound.setPackageCount(packageCount); outboundOrders.updateById(outbound);
|
||||
|
||||
LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||
delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId()));
|
||||
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||
delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId());
|
||||
delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName());
|
||||
delivery.setDestinationType(destinationType); delivery.setDestinationWarehouseId(trim(request.destinationWarehouseId()));
|
||||
delivery.setDestinationWarehouseCode(trim(request.destinationWarehouseCode()));
|
||||
delivery.setDestinationWarehouseName(trim(request.destinationWarehouseName()));
|
||||
delivery.setDeliveryAddress(trim(request.deliveryAddress())); delivery.setContactName(trim(request.contactName()));
|
||||
delivery.setContactPhone(trim(request.contactPhone())); delivery.setDeliveryGroupKey(destinationType + ":" +
|
||||
firstText(request.destinationWarehouseCode(), request.deliveryAddress()));
|
||||
delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryMethod(firstText(request.deliveryMethod(), "TRUCK"));
|
||||
delivery.setPackageCount(packageCount); delivery.setPalletCount(selectedPallets.size());
|
||||
delivery.setTotalWeightKg(selectedPallets.stream().map(WarehouseTransferPallet::getGrossWeightKg)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
delivery.setTotalVolumeCbm(selectedPallets.stream().map(WarehouseTransferPallet::getVolumeCbm)
|
||||
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); delivery.setRemark(trim(request.remark()));
|
||||
delivery.setIdempotencyKey(childKey(key, "DELIVERY")); delivery.setCreatedBy(operatorId); delivery.setVersion(0L);
|
||||
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||
recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "仓储转运出库自动生成派送单", now);
|
||||
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId);
|
||||
task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId());
|
||||
task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
transfer.setStatus("WAIT_OUTBOUND");
|
||||
if ("FBA".equals(destinationType)) transfer.setFbaWarehouseCode(request.destinationWarehouseCode().trim());
|
||||
transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
|
||||
if (tailOrder != null && customerId.equals(tailOrder.getCustomerId())) {
|
||||
tailOrder.setCurrentStatus("WAIT_OUTBOUND"); tailOrder.setOutboundOrderNo(outbound.getOutboundNo());
|
||||
tailOrder.setDeliveryOrderNo(delivery.getDeliveryNo()); tailOrder.setFbaWarehouseCode(trim(request.destinationWarehouseCode()));
|
||||
tailOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:WAIT_OUTBOUND"); tailOrder.setUpdatedAt(now); tailOrders.updateById(tailOrder);
|
||||
}
|
||||
return outboundDetail(outbound.getOutboundNo());
|
||||
}
|
||||
|
||||
private String validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
|
||||
String warehouseId = null;
|
||||
Map<String, Long> requestedPackages = new LinkedHashMap<>();
|
||||
@@ -541,6 +698,40 @@ public class DeliveryTransferService {
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
if (request.inventoryPositionId() == null) throw conflict("仓储转运出库必须确认实际库存位置");
|
||||
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
|
||||
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId())
|
||||
|| !warehouseId.equals(selectedPosition.getOverseasWarehouseId())
|
||||
|| !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) {
|
||||
throw conflict("确认的库存位置不属于该仓储转运出库托盘");
|
||||
}
|
||||
InventoryBalance balance = inventoryBalances.selectById(selectedPosition.getBalanceId());
|
||||
if (!matchesOutboundItem(customerId, warehouseId, item, balance)) {
|
||||
throw conflict("确认的库存SKU或入仓码与出库明细不一致");
|
||||
}
|
||||
if (request.packages() > value(item.getPackageCount()) - value(item.getPickedPackageCount())) {
|
||||
throw conflict("出库数量超过仓储转运出库明细余量");
|
||||
}
|
||||
inventoryCommands.allocate(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":ALLOCATE", "仓储转运出库分配"));
|
||||
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":PICK", "仓储转运出库拣货"));
|
||||
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
selectedPosition.getId(), request.packages(), item.getOrderRefs(), outbound.getOutboundNo(), null,
|
||||
request.idempotencyKey() + ":SHIP", "仓储转运出库"));
|
||||
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId);
|
||||
pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId());
|
||||
pick.setInventoryPositionId(selectedPosition.getId()); pick.setPackages(request.packages());
|
||||
pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
|
||||
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue());
|
||||
item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED");
|
||||
outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound);
|
||||
task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task);
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
InventoryPosition selectedPosition = inventoryPositions.selectById(request.inventoryPositionId());
|
||||
if (selectedPosition == null || !customerId.equals(selectedPosition.getCustomerId()) || !Objects.equals(selectedPosition.getPalletId(), item.getWarehousePalletId())) throw conflict("Scanned inventory position does not belong to the outbound pallet");
|
||||
if (!warehouseId.equals(selectedPosition.getOverseasWarehouseId())) {
|
||||
@@ -612,8 +803,8 @@ public class DeliveryTransferService {
|
||||
if (outbound == null || !boundWarehouseId.equals(outbound.getOverseasWarehouseId())) {
|
||||
throw notFound("Outbound order not found in the bound warehouse");
|
||||
}
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(outbound.getOutboundType())) {
|
||||
throw conflict("整托扫码仅适用于一件代发出库");
|
||||
if (!Set.of("ONE_CLICK_DROPSHIP", "RELABEL_OUTBOUND", "WAREHOUSE_TRANSFER_OUTBOUND").contains(outbound.getOutboundType())) {
|
||||
throw conflict("整托扫码仅适用于一件代发或换标出库");
|
||||
}
|
||||
if (!Set.of("PENDING", "IN_PROGRESS").contains(task.getStatus())) {
|
||||
throw conflict("PDA task is not available for picking");
|
||||
@@ -695,6 +886,23 @@ public class DeliveryTransferService {
|
||||
new RelabelRequests.ChangeStatus("COMPLETED", "换标出库PDA作业完成"));
|
||||
}
|
||||
}
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
List<String> transferNos = items.stream().map(OutboundPalletItem::getOrderRefs)
|
||||
.filter(StringUtils::hasText).distinct().toList();
|
||||
for (String transferNo : transferNos) {
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
|
||||
if (transfer != null && "WAIT_OUTBOUND".equals(transfer.getStatus())) {
|
||||
transfer.setStatus("OUTBOUNDED"); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
|
||||
warehouseTransfers.updateById(transfer);
|
||||
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
if (order != null && customerId.equals(order.getCustomerId())) {
|
||||
order.setCurrentStatus("IN_TRANSIT");
|
||||
order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now);
|
||||
tailOrders.updateById(order);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return taskResult(outbound, task);
|
||||
}
|
||||
|
||||
@@ -724,6 +932,23 @@ public class DeliveryTransferService {
|
||||
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
|
||||
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
|
||||
for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); }
|
||||
if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) {
|
||||
for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs)
|
||||
.filter(StringUtils::hasText).distinct().toList()) {
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo);
|
||||
if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue;
|
||||
String restored = StringUtils.hasText(transfer.getFbaWarehouseCode())
|
||||
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
|
||||
transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer);
|
||||
LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId());
|
||||
if (order != null && customerId.equals(order.getCustomerId())) {
|
||||
order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored);
|
||||
order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now);
|
||||
tailOrders.updateById(order);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!existingDeliveries.isEmpty()) {
|
||||
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getOutboundOrderNo, outboundNo))) {
|
||||
String from = order.getCurrentStatus(); order.setCurrentStatus("RECEIVED"); order.setSubOrderStatus(null); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); tailOrders.updateById(order);
|
||||
@@ -958,17 +1183,20 @@ public class DeliveryTransferService {
|
||||
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
|
||||
value(candidate.getAvailablePackages()));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName()); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
|
||||
private LastlegOrder firstTailOrder(String customerId, List<DispatchItem> items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); }
|
||||
private String deliveryGroupKey(DispatchItem item) {
|
||||
if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) {
|
||||
return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim();
|
||||
}
|
||||
String code = firstText(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode());
|
||||
if (StringUtils.hasText(code)) return "CODE:" + code.toUpperCase(Locale.ROOT);
|
||||
if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim();
|
||||
throw conflict("Dispatch pallet is missing its destination FBA warehouse");
|
||||
throw conflict("Dispatch pallet is missing its destination");
|
||||
}
|
||||
private String resolveLxCodes(String customerId, List<DispatchItem> items) {
|
||||
List<String> inboundCodes = items.stream().map(DispatchItem::getInboundCode)
|
||||
@@ -1029,6 +1257,14 @@ public class DeliveryTransferService {
|
||||
return measuredWeight.add(orderWeight);
|
||||
}
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException("无法生成请求摘要", error);
|
||||
}
|
||||
}
|
||||
private static Long parseLong(String value) {
|
||||
try { return StringUtils.hasText(value) ? Long.parseLong(value.trim()) : null; }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ public class SmartDispatchController {
|
||||
@RequestParam(required = false) String sourceStage,
|
||||
@RequestParam(required = false) String draftToken) {
|
||||
requirePlatform(user);
|
||||
if (planIds == null || planIds.isEmpty()) {
|
||||
if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) {
|
||||
return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken));
|
||||
}
|
||||
return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
|
||||
|
||||
@@ -30,6 +30,11 @@ public class DispatchItem {
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String fbaWarehouseCode;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private Integer packageCount;
|
||||
private BigDecimal volumeCbm;
|
||||
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
|
||||
|
||||
+16
-6
@@ -64,16 +64,20 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
|
||||
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
|
||||
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
|
||||
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE 1=1 " +
|
||||
"WHERE ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " +
|
||||
"(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " +
|
||||
"AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND ip.customer_id=#{customerId}</if> " +
|
||||
"<if test='planIds != null and planIds.size() > 0'> AND p.plan_id IN " +
|
||||
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></if> " +
|
||||
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
|
||||
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
|
||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
@@ -146,13 +150,18 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id " +
|
||||
"WHERE ri.customer_id=ip.customer_id AND rp.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND ri.fba_carton_mark IS NOT NULL AND ri.fba_carton_mark != '' " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks " +
|
||||
"AND ra.status NOT IN ('REJECTED','CANCELLED')) AS relabelCartonMarks," +
|
||||
"MIN(l.destination_type) AS destinationType,MIN(l.delivery_address) AS deliveryAddress," +
|
||||
"MIN(l.postal_code) AS postalCode,MIN(l.contact_name) AS contactName,MIN(l.contact_phone) AS contactPhone " +
|
||||
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"AND ((l.destination_type='FBA' AND NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL) OR " +
|
||||
"(l.destination_type='PRIVATE' AND NULLIF(TRIM(l.delivery_address),'') IS NOT NULL AND NULLIF(TRIM(l.postal_code),'') IS NOT NULL " +
|
||||
"AND NULLIF(TRIM(l.contact_name),'') IS NOT NULL AND NULLIF(TRIM(l.contact_phone),'') IS NOT NULL)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
@@ -184,7 +193,8 @@ public interface DispatchCandidateMapper {
|
||||
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," +
|
||||
"SUM(ri.notified_package_count) AS packageCount,MAX(wtp.volume_cbm) AS volumeCbm,'RELABEL' AS sourceStage," +
|
||||
"NULL AS cartonMarks,GROUP_CONCAT(DISTINCT COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) " +
|
||||
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks " +
|
||||
"ORDER BY COALESCE(NULLIF(ri.fba_carton_mark,''),ri.carton_mark) SEPARATOR ',') AS relabelCartonMarks," +
|
||||
"'FBA' AS destinationType,NULL AS deliveryAddress,NULL AS postalCode,NULL AS contactName,NULL AS contactPhone " +
|
||||
"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_inventory_position pos ON pos.id=ri.source_inventory_position_id AND pos.customer_id=a.customer_id " +
|
||||
"LEFT JOIN ll_warehouse_transfer_order wto ON wto.relabel_no=a.relabel_no AND wto.customer_id=a.customer_id " +
|
||||
@@ -193,7 +203,7 @@ public interface DispatchCandidateMapper {
|
||||
"WHERE a.delivery_method='TRUCK' 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},'%') </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>" +
|
||||
"AND pos.hold_packages > 0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||
"ON existing_dispatch.id=di.dispatch_order_id AND existing_dispatch.customer_id=di.customer_id " +
|
||||
|
||||
+8
-3
@@ -30,7 +30,12 @@ public record DispatchPalletCandidate(
|
||||
BigDecimal volumeCbm,
|
||||
String sourceStage,
|
||||
String cartonMarks,
|
||||
String relabelCartonMarks
|
||||
String relabelCartonMarks,
|
||||
String destinationType,
|
||||
String deliveryAddress,
|
||||
String postalCode,
|
||||
String contactName,
|
||||
String contactPhone
|
||||
) {
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
String overseasWarehouseId, String overseasWarehouseName, String orderNo,
|
||||
@@ -41,7 +46,7 @@ public record DispatchPalletCandidate(
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, overseasWarehouseId,
|
||||
overseasWarehouseName, orderNo, orderRefs, inboundCode, palletNo, destinationWarehouseId,
|
||||
destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, packageCount, volumeCbm,
|
||||
sourceStage, null, null);
|
||||
sourceStage, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||
@@ -51,7 +56,7 @@ public record DispatchPalletCandidate(
|
||||
BigDecimal volumeCbm, String sourceStage) {
|
||||
this(planId, planPalletId, warehousePalletId, orderId, null, null, null, null, null, null, null, null, null, null, orderNo, orderRefs, inboundCode,
|
||||
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null);
|
||||
fbaWarehouseCode, packageCount, volumeCbm, sourceStage, null, null, null, null, null, null, null);
|
||||
}
|
||||
public String sourcePalletKey() {
|
||||
// Once received, the physical pallet is the shared resource even when it contains
|
||||
|
||||
+32
-5
@@ -66,6 +66,7 @@ public class DispatchService {
|
||||
@Resource private LastlegOrderMapper tailOrders;
|
||||
@Resource private OutboundOrderMapper outboundOrders;
|
||||
@Resource private RelabelApplicationMapper relabelApplications;
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
|
||||
|
||||
public String resolveCustomerScopeForPlans(List<Long> planIds) {
|
||||
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
|
||||
@@ -107,12 +108,14 @@ public class DispatchService {
|
||||
public List<DispatchPalletCandidate> platformCandidates(String orderNo, String fbaWarehouseCode,
|
||||
String sourceStage, String draftToken) {
|
||||
validateSourceStage(sourceStage);
|
||||
List<DispatchPalletCandidate> result = candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
List<DispatchPalletCandidate> result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
|
||||
trimToNull(sourceStage), trimToNull(draftToken));
|
||||
if (!StringUtils.hasText(sourceStage) || "RECEIVED".equals(sourceStage)) {
|
||||
if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
|
||||
List<DispatchPalletCandidate> relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null);
|
||||
if (relabel != null && !relabel.isEmpty()) {
|
||||
result = java.util.stream.Stream.concat(result.stream(), relabel.stream()).toList();
|
||||
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
|
||||
!StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "")
|
||||
.toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -270,6 +273,11 @@ public class DispatchService {
|
||||
if (selected.size() != palletIds.size()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant");
|
||||
}
|
||||
if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) {
|
||||
boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType()));
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba
|
||||
? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车");
|
||||
}
|
||||
assertNoDetainedOrders(customerId, selected);
|
||||
boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId)
|
||||
.anyMatch(value -> value == null || value.isBlank());
|
||||
@@ -396,6 +404,11 @@ public class DispatchService {
|
||||
item.setDestinationWarehouseCode(candidate.destinationWarehouseCode());
|
||||
item.setDestinationWarehouseName(candidate.destinationWarehouseName());
|
||||
item.setFbaWarehouseCode(candidate.fbaWarehouseCode());
|
||||
item.setDestinationType(candidate.destinationType());
|
||||
item.setDeliveryAddress(candidate.deliveryAddress());
|
||||
item.setPostalCode(candidate.postalCode());
|
||||
item.setContactName(candidate.contactName());
|
||||
item.setContactPhone(candidate.contactPhone());
|
||||
item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0));
|
||||
item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO));
|
||||
item.setSourceStage(candidate.sourceStage());
|
||||
@@ -433,6 +446,7 @@ public class DispatchService {
|
||||
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
|
||||
}
|
||||
relabelBindingGuard.requireBound(application);
|
||||
List<DispatchPalletCandidate> selected = candidates.relabelAvailable(customerId, null, request.relabelNo());
|
||||
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
|
||||
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
|
||||
@@ -653,10 +667,23 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
private static String candidateFbaCode(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode())
|
||||
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" "));
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static boolean hasValidDestination(DispatchPalletCandidate candidate) {
|
||||
if ("PRIVATE".equals(candidate.destinationType())) {
|
||||
return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode())
|
||||
&& StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone());
|
||||
}
|
||||
return StringUtils.hasText(candidate.fbaWarehouseCode())
|
||||
|| StringUtils.hasText(candidate.destinationWarehouseCode());
|
||||
}
|
||||
|
||||
private static String itemFbaCode(DispatchItem item) {
|
||||
return StringUtils.hasText(item.getFbaWarehouseCode())
|
||||
? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode());
|
||||
@@ -716,8 +743,8 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
private static void validateSourceStage(String sourceStage) {
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
|
||||
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,12 @@ public record LastlegOrderSummary(
|
||||
String nodeSource,
|
||||
int version,
|
||||
Integer palletCount,
|
||||
@Schema(description = "客户预报箱数")
|
||||
Integer packageCount,
|
||||
@Schema(description = "PDA 实收箱数;尚未完成收货时为空")
|
||||
Integer receivedPackageCount,
|
||||
@Schema(description = "实收减预报箱数;尚未完成收货时为空")
|
||||
Integer packageDifference,
|
||||
BigDecimal volumeCbm,
|
||||
Integer productQuantity,
|
||||
BigDecimal volumeWeightKg,
|
||||
|
||||
@@ -44,6 +44,7 @@ public class LastlegOrder {
|
||||
private Integer version;
|
||||
private Integer palletCount;
|
||||
private Integer packageCount;
|
||||
private Integer receivedPackageCount;
|
||||
private Integer pickedPackageCount;
|
||||
private Integer shippedPackageCount;
|
||||
private BigDecimal volumeCbm;
|
||||
|
||||
+4
@@ -21,6 +21,10 @@ public class InboundPlanLine {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private BigDecimal volumeCbm;
|
||||
private Integer requiredPackageCount;
|
||||
private Integer plannedPalletCount;
|
||||
|
||||
+5
@@ -22,6 +22,11 @@ public class InboundPlanPallet {
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String destinationType;
|
||||
private String deliveryAddress;
|
||||
private String postalCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
private BigDecimal capacityCbm;
|
||||
private BigDecimal allocatedVolumeCbm;
|
||||
private Integer sequenceNo;
|
||||
|
||||
+23
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -293,6 +295,27 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway {
|
||||
return palletBatch("/internal/v1/warehouse-pallet-batches/compensate", request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) {
|
||||
ResponseEntity<byte[]> response = client.get()
|
||||
.uri(builder -> builder.path("/internal/v1/warehouse-pallets/{warehouseId}/{palletId}/label")
|
||||
.queryParam("transferNo", transferNo).build(warehouseId, palletId))
|
||||
.header("X-Internal-Service-Token", internalToken)
|
||||
.header("X-Request-Id", requestId == null ? "" : requestId)
|
||||
.retrieve().onStatus(HttpStatusCode::isError, (request, result) -> {
|
||||
throw new ResponseStatusException(result.getStatusCode().is4xxClientError()
|
||||
? HttpStatus.NOT_FOUND : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Warehouse pallet label printing failed");
|
||||
}).toEntity(byte[].class);
|
||||
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
|
||||
if (content.length == 0) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"Warehouse pallet label printing returned an empty PDF");
|
||||
String fileName = response.getHeaders().getContentDisposition().getFilename();
|
||||
String contentType = response.getHeaders().getContentType() == null
|
||||
? MediaType.APPLICATION_PDF_VALUE : response.getHeaders().getContentType().toString();
|
||||
return new PrintableFile(content, fileName == null ? "pallet-label.pdf" : fileName, contentType);
|
||||
}
|
||||
|
||||
private PalletBatchResponse palletBatch(String path, Object body) {
|
||||
PalletBatchEnvelope response = client.post().uri(path)
|
||||
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
|
||||
|
||||
+4
@@ -39,6 +39,9 @@ public interface WarehouseResourceGateway {
|
||||
default PalletBatchResponse compensatePalletBatch(PalletBatchCompensation request) {
|
||||
throw new UnsupportedOperationException("Warehouse pallet batch compensation is not configured");
|
||||
}
|
||||
default PrintableFile printPalletLabel(long warehouseId, long palletId, String transferNo, String requestId) {
|
||||
throw new UnsupportedOperationException("Warehouse pallet label printing is not configured");
|
||||
}
|
||||
|
||||
record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId,
|
||||
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
|
||||
@@ -67,4 +70,5 @@ public interface WarehouseResourceGateway {
|
||||
record PalletBatchItem(long customerId, String businessKey, String inboundPlanNo, int sequenceNo,
|
||||
long palletId, String palletCode, long locationId, long resourceLockId,
|
||||
String status) { }
|
||||
record PrintableFile(byte[] content, String fileName, String contentType) { }
|
||||
}
|
||||
|
||||
-3
@@ -511,9 +511,6 @@ public class InventoryCommandService {
|
||||
String skuCode = sku(requestedSkuCode);
|
||||
String destinationId = trim(destinationWarehouseId);
|
||||
String destinationCode = trim(destinationWarehouseCode);
|
||||
if (destinationId == null && destinationCode == null) {
|
||||
throw badRequest("Destination warehouse ID or code is required");
|
||||
}
|
||||
if (destinationCode == null) destinationCode = "";
|
||||
InventoryBalance balance = balances.lockDimension(customerId, frozenWarehouseId,
|
||||
destinationCode, inboundOrderId, inboundCode);
|
||||
|
||||
@@ -2,9 +2,22 @@ package com.baiyue.lastleg.model;
|
||||
|
||||
/** Standard main-process statuses for a last-leg order. */
|
||||
public enum LastlegOrderStatus {
|
||||
PENDING_REVIEW("待审核"),
|
||||
INBOUND_MEASURING("入库测量中"),
|
||||
PENDING_ASSIGNMENT("待分配"),
|
||||
PENDING_INBOUND("待入库"),
|
||||
INBOUND_COMPLETED("入库完成"),
|
||||
WAIT_RELABEL("待换标"),
|
||||
WAIT_FBA_DESTINATION("待确认目的仓"),
|
||||
READY_FOR_DISPATCH("待创建出库单"),
|
||||
WAIT_OUTBOUND("待出库"),
|
||||
ALLOCATED("已分配库存"),
|
||||
OUTBOUNDED("已出库"),
|
||||
PARTIALLY_RECEIVED("部分收货"),
|
||||
RECEIVED("已收货"),
|
||||
IN_TRANSIT("运输中"),
|
||||
COMPLETED("已完成"),
|
||||
CANCELLED("已取消"),
|
||||
PENDING_DELIVERY("待派送"),
|
||||
DELIVERING("派送中"),
|
||||
DELIVERED("派送完成");
|
||||
|
||||
+7
@@ -28,6 +28,13 @@ public class PdaPalletOperationController {
|
||||
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/relabel-sources")
|
||||
public ApiResponse<java.util.List<PalletOperationResponses.RelabelSource>> relabelSources(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody PalletOperationRequests.RelabelSources request) {
|
||||
return ApiResponse.success(service.relabelSources(PdaWarehouseIdentity.require(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/relabel-pallet-label")
|
||||
public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+9
-2
@@ -13,6 +13,7 @@ public final class PalletOperationRequests {
|
||||
private PalletOperationRequests() { }
|
||||
|
||||
public record Scan(@NotBlank @Size(max = 128) String code) { }
|
||||
public record RelabelSources(@NotBlank @Size(max = 64) String relabelNo) { }
|
||||
public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey) { }
|
||||
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
|
||||
@@ -33,13 +34,19 @@ public final class PalletOperationRequests {
|
||||
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
|
||||
@Size(max = 500) String reason) { }
|
||||
|
||||
/** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */
|
||||
/** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */
|
||||
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@NotBlank @Size(max = 64) String relabelNo,
|
||||
@Valid PalletExpectation source,
|
||||
@Valid PalletExpectation target,
|
||||
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
|
||||
@Size(max = 500) String reason) { }
|
||||
@Size(max = 500) String reason,
|
||||
@Valid MoveLine actualSource) {
|
||||
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
|
||||
PalletExpectation target, List<String> cartonMarks, String reason) {
|
||||
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@Valid PalletExpectation source,
|
||||
|
||||
+3
@@ -5,6 +5,9 @@ import java.util.List;
|
||||
|
||||
public final class PalletOperationResponses {
|
||||
private PalletOperationResponses() { }
|
||||
public record RelabelSource(long positionId, long positionVersion, String palletCode,
|
||||
String locationCode, String inboundCode, long stockPackages,
|
||||
long movablePackages, List<String> cartonMarks) { }
|
||||
|
||||
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
|
||||
String inboundCode, String skuCode, String productName,
|
||||
|
||||
+6
@@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation>
|
||||
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
|
||||
@Select(CONTENT_SELECT + "WHERE b.customer_id=#{customerId} AND b.overseas_warehouse_id=#{warehouseId} " +
|
||||
"AND b.id=#{balanceId} AND p.pallet_id>0 " +
|
||||
"AND (p.available_packages+p.hold_packages)>0 ORDER BY p.pallet_no,p.id")
|
||||
List<PalletInventoryRow> relabelSources(@Param("customerId") String customerId,
|
||||
@Param("warehouseId") String warehouseId, @Param("balanceId") long balanceId);
|
||||
|
||||
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
|
||||
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||
"ORDER BY b.customer_id,b.inbound_code,p.id")
|
||||
|
||||
+211
-15
@@ -78,13 +78,42 @@ public class PdaPalletOperationService {
|
||||
return content(identity.overseasWarehouseId(), pallet);
|
||||
}
|
||||
|
||||
public List<PalletOperationResponses.RelabelSource> relabelSources(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.RelabelSources request) {
|
||||
RelabelApplication app = relabelApplications.findAnyByRelabelNo(request.relabelNo());
|
||||
requireRelabelWork(identity, app);
|
||||
Set<String> boundMarks = boundRelabelMarks(app);
|
||||
List<RelabelItem> items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
|
||||
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()));
|
||||
Map<Long, List<RelabelItem>> byBalance = new LinkedHashMap<>();
|
||||
for (RelabelItem item : items) {
|
||||
InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId());
|
||||
if (anchor == null || !Objects.equals(anchor.getCustomerId(), app.getCustomerId())
|
||||
|| !Objects.equals(anchor.getOverseasWarehouseId(), app.getOverseasWarehouseId()))
|
||||
throw conflict("换标单库存来源缺失,请核对原入仓单");
|
||||
byBalance.computeIfAbsent(anchor.getBalanceId(), ignored -> new ArrayList<>()).add(item);
|
||||
}
|
||||
List<PalletOperationResponses.RelabelSource> result = new ArrayList<>();
|
||||
byBalance.forEach((balanceId, group) -> {
|
||||
for (PalletInventoryRow row : mapper.relabelSources(app.getCustomerId(), app.getOverseasWarehouseId(), balanceId)) {
|
||||
long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId()))
|
||||
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
|
||||
result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()),
|
||||
row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
|
||||
value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
|
||||
+ value(row.getPickedPackages()),
|
||||
value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())),
|
||||
group.stream().map(RelabelItem::getCartonMark).filter(mark -> !boundMarks.contains(mark)).toList()));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.NewRelabelPallet request) {
|
||||
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
|
||||
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
|
||||
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
|
||||
throw conflict("换标单尚未完成客户确认,不能生成新托盘");
|
||||
}
|
||||
requireRelabelWork(identity, application);
|
||||
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
|
||||
throw conflict("换标单不属于 Token 冻结的海外仓");
|
||||
}
|
||||
@@ -153,9 +182,7 @@ public class PdaPalletOperationService {
|
||||
if (replay != null) return replay;
|
||||
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
|
||||
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
|
||||
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
|
||||
throw conflict("换标单尚未完成客户确认,不能执行拆托");
|
||||
}
|
||||
requireRelabelWork(identity, application);
|
||||
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
|
||||
throw conflict("换标单不属于 Token 冻结的海外仓");
|
||||
}
|
||||
@@ -177,29 +204,61 @@ public class PdaPalletOperationService {
|
||||
}
|
||||
Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
|
||||
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
|
||||
Map<Long, InventoryPosition> anchors = new LinkedHashMap<>();
|
||||
InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId());
|
||||
if (request.actualSource() != null && (actual == null
|
||||
|| !Objects.equals(actual.getCustomerId(), application.getCustomerId())
|
||||
|| !Objects.equals(actual.getPalletId(), source.palletId())
|
||||
|| !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId())
|
||||
|| value(actual.getVersion()) != request.actualSource().expectedPositionVersion()))
|
||||
throw conflict("实际来源托盘库存已变化,请刷新后确认");
|
||||
if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank())
|
||||
throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源");
|
||||
if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains))
|
||||
throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱");
|
||||
if ("PROCESSING".equals(application.getStatus())) {
|
||||
var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId());
|
||||
for (RelabelItem item : selected) {
|
||||
Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
|
||||
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
|
||||
.eq("item_id", item.getId()).eq("result", "RELABELLED"));
|
||||
if (completed == null || completed < value(item.getNotifiedPackageCount()))
|
||||
throw conflict("请先完成本箱全部换标作业,再进行组托");
|
||||
}
|
||||
}
|
||||
for (RelabelItem item : selected) {
|
||||
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|
||||
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
|
||||
InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId());
|
||||
if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId())
|
||||
|| !Objects.equals(position.getPalletId(), source.palletId())
|
||||
|| (actual == null && !Objects.equals(position.getPalletId(), source.palletId()))
|
||||
|| (actual != null && (!Objects.equals(position.getBalanceId(), actual.getBalanceId())
|
||||
|| !Objects.equals(position.getBatchNo(), actual.getBatchNo())))
|
||||
|| !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
|
||||
throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致");
|
||||
throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次");
|
||||
}
|
||||
packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum);
|
||||
itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item);
|
||||
anchors.put(position.getId(), position);
|
||||
Long sourcePosition = actual == null ? position.getId() : actual.getId();
|
||||
packagesByPosition.merge(sourcePosition, item.getNotifiedPackageCount().longValue(), Long::sum);
|
||||
itemsByPosition.computeIfAbsent(sourcePosition, ignored -> new ArrayList<>()).add(item);
|
||||
}
|
||||
if (actual != null && request.actualSource().packages() != packagesByPosition.get(actual.getId()))
|
||||
throw conflict("拆出箱数必须等于本次确认绑定的换标箱数");
|
||||
List<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> {
|
||||
InventoryPosition position = positions.selectById(entry.getKey());
|
||||
if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足");
|
||||
long own = selected.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), position.getId()))
|
||||
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
|
||||
if (actual == null ? value(position.getHoldPackages()) < entry.getValue()
|
||||
: value(position.getHoldPackages()) < own || value(position.getAvailablePackages()) < entry.getValue() - own)
|
||||
throw conflict("来源托盘可拆箱数不足,不能占用其他单据冻结或已分配库存");
|
||||
return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(),
|
||||
source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
|
||||
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD);
|
||||
}).toList();
|
||||
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
|
||||
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
|
||||
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
|
||||
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
|
||||
new RelabelMutation(application, itemsByPosition));
|
||||
new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors));
|
||||
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
|
||||
? "" : ":" + request.reason().trim());
|
||||
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
|
||||
@@ -288,6 +347,10 @@ public class PdaPalletOperationService {
|
||||
throw conflict("库存所在托盘已变化,请重新扫码");
|
||||
}
|
||||
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
|
||||
if (plan.relabelMutation() != null && !plan.relabelMutation().anchors().isEmpty()) {
|
||||
reconcileRelabelHold(identity, operation, plan.relabelMutation(), move, key);
|
||||
locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId());
|
||||
}
|
||||
if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages());
|
||||
else requireMovable(locked, move.packages());
|
||||
customers.add(locked.getCustomerId());
|
||||
@@ -296,6 +359,8 @@ public class PdaPalletOperationService {
|
||||
}
|
||||
if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems(
|
||||
plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets);
|
||||
if (plan.relabelMutation() != null)
|
||||
recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets);
|
||||
for (PalletChange change : plan.changes()) {
|
||||
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
|
||||
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
|
||||
@@ -320,7 +385,8 @@ public class PdaPalletOperationService {
|
||||
|
||||
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
|
||||
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
|
||||
if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus())
|
||||
if (locked != null) requireUnallocatedRelabel(locked);
|
||||
if (locked == null || !Objects.equals(locked.getStatus(), mutation.application().getStatus())
|
||||
|| !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId())
|
||||
|| !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId))
|
||||
|| !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) {
|
||||
@@ -328,6 +394,52 @@ public class PdaPalletOperationService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Redistributes only this application's hold within the same inbound balance; physical totals stay unchanged. */
|
||||
private void reconcileRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation,
|
||||
RelabelMutation mutation, PlannedMove move, String key) {
|
||||
requireRelabelWork(identity, mutation.application());
|
||||
List<RelabelItem> selected = mutation.itemsByPosition().get(move.positionId());
|
||||
Set<String> alreadyBound = boundRelabelMarks(mutation.application());
|
||||
if (selected.stream().anyMatch(item -> alreadyBound.contains(item.getCartonMark())))
|
||||
throw conflict("换标箱已在本轮组托,请刷新后重试");
|
||||
requireRelabelDestination(identity.overseasWarehouseId(), selected, physical.resolve(move.targetPalletCode()));
|
||||
Map<Long, Long> release = new LinkedHashMap<>();
|
||||
for (RelabelItem item : selected) {
|
||||
RelabelItem current = relabelItems.selectById(item.getId());
|
||||
if (current == null || !Objects.equals(current.getSourceInventoryPositionId(), item.getSourceInventoryPositionId())
|
||||
|| !Objects.equals(current.getNotifiedPackageCount(), item.getNotifiedPackageCount()))
|
||||
throw conflict("换标箱的绑定已变化,请刷新后重试");
|
||||
if (!Objects.equals(item.getSourceInventoryPositionId(), move.positionId()))
|
||||
release.merge(item.getSourceInventoryPositionId(), (long) value(item.getNotifiedPackageCount()), Long::sum);
|
||||
}
|
||||
InventoryPosition actual = positions.selectById(move.positionId());
|
||||
long required = release.values().stream().mapToLong(Long::longValue).sum();
|
||||
if (value(actual.getAvailablePackages()) < required) throw conflict("实际来源托盘可用箱数不足");
|
||||
InventoryBalance balance = balances.selectById(actual.getBalanceId());
|
||||
for (var entry : release.entrySet()) {
|
||||
PalletInventoryRow locked = mapper.lockPosition(mutation.application().getOverseasWarehouseId(), entry.getKey());
|
||||
InventoryPosition snapshot = mutation.anchors().get(entry.getKey());
|
||||
if (locked == null || value(locked.getVersion()) != value(snapshot.getVersion())
|
||||
|| !Objects.equals(locked.getBalanceId(), actual.getBalanceId())
|
||||
|| value(locked.getHoldPackages()) < entry.getValue())
|
||||
throw conflict("原冻结库存已变化,请刷新后重试");
|
||||
InventoryPosition anchor = positions.selectById(entry.getKey());
|
||||
adjustRelabelHold(identity, operation, balance, anchor, -entry.getValue(), key + ":RELEASE:" + anchor.getId());
|
||||
}
|
||||
if (required > 0) adjustRelabelHold(identity, operation, balance, actual, required, key + ":FREEZE:" + actual.getId());
|
||||
}
|
||||
|
||||
private void adjustRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation,
|
||||
InventoryBalance balance, InventoryPosition position, long delta, String key) {
|
||||
long before = value(position.getHoldPackages());
|
||||
position.setHoldPackages(before + delta);
|
||||
position.setAvailablePackages(value(position.getAvailablePackages()) - delta);
|
||||
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now());
|
||||
positions.updateById(position);
|
||||
ledger(identity, operation, balance, position, key, before, delta, before + delta,
|
||||
position.getPalletNo(), position.getPalletNo(), "现场按原入仓单确认拆托来源:冻结桶调整,可用桶反向调整,实物总数不变");
|
||||
}
|
||||
|
||||
private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
|
||||
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
|
||||
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
|
||||
@@ -458,6 +570,89 @@ public class PdaPalletOperationService {
|
||||
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
|
||||
}
|
||||
|
||||
@jakarta.annotation.Resource
|
||||
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper relabelWorkTasks;
|
||||
@jakarta.annotation.Resource
|
||||
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper relabelWorkFacts;
|
||||
@jakarta.annotation.Resource
|
||||
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper relabelWorkHistory;
|
||||
|
||||
private Set<String> boundRelabelMarks(RelabelApplication app) {
|
||||
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
|
||||
if (task == null) return Set.of();
|
||||
String round = Objects.toString(task.getReviewedAt(), "INITIAL");
|
||||
Set<String> marks = new HashSet<>();
|
||||
var records = relabelWorkHistory.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>()
|
||||
.eq("customer_id", app.getCustomerId()).eq("task_id", task.getId()).eq("action", "PALLET_BIND"));
|
||||
for (var record : records) {
|
||||
try {
|
||||
var data = objectMapper.readTree(record.getDetailJson());
|
||||
if (round.equals(data.path("round").asText())) data.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
|
||||
} catch (JsonProcessingException failure) { throw conflict("换标组托记录无法读取,请核对作业历史"); }
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation,
|
||||
Map<Long, InventoryPosition> targets) {
|
||||
var app = mutation.application();
|
||||
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
|
||||
if (task == null) throw conflict("换标作业任务不存在,不能记录组托");
|
||||
var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
|
||||
record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId());
|
||||
record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND");
|
||||
record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus());
|
||||
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
|
||||
record.setCreatedAt(LocalDateTime.now());
|
||||
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
|
||||
"cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(),
|
||||
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList())));
|
||||
relabelWorkHistory.insert(record);
|
||||
}
|
||||
|
||||
private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) {
|
||||
if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus()))
|
||||
throw conflict("当前换标状态不能组托");
|
||||
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId())))
|
||||
throw conflict("换标单不属于当前海外仓");
|
||||
requireUnallocatedRelabel(application);
|
||||
if ("PROCESSING".equals(application.getStatus())) {
|
||||
var task = relabelWorkTasks.find(application.getCustomerId(), application.getId());
|
||||
if (task == null || !"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey()))
|
||||
throw conflict("请先认领当前换标任务");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireUnallocatedRelabel(RelabelApplication application) {
|
||||
String fulfillment = application.getFulfillmentStatus();
|
||||
if (org.springframework.util.StringUtils.hasText(application.getOutboundOrderNo())
|
||||
|| (org.springframework.util.StringUtils.hasText(fulfillment)
|
||||
&& !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(fulfillment)))
|
||||
throw conflict("换标单已进入排车或出库流程,请先解除占用后再改绑托盘");
|
||||
}
|
||||
|
||||
private void requireRelabelDestination(long warehouseId, List<RelabelItem> selected,
|
||||
PalletPhysicalGateway.PalletReference target) {
|
||||
Set<String> destinations = new HashSet<>();
|
||||
selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode())));
|
||||
if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托");
|
||||
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) {
|
||||
var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
|
||||
.eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId()));
|
||||
long held = 0;
|
||||
for (RelabelItem item : bound) {
|
||||
var app = relabelApplications.selectById(item.getApplicationId());
|
||||
if (app != null && Set.of("PROCESSING", "CUSTOMER_CONFIRM_PENDING", "READY_FOR_OUTBOUND").contains(app.getStatus())) {
|
||||
held += value(item.getNotifiedPackageCount()); destinations.add(text(item.getDestinationFbaWarehouseCode()));
|
||||
}
|
||||
}
|
||||
if (value(row.getAvailablePackages()) > 0 || value(row.getAllocatedPackages()) > 0
|
||||
|| value(row.getPickedPackages()) > 0 || value(row.getHoldPackages()) > held)
|
||||
destinations.add(text(row.getDestinationWarehouseCode()));
|
||||
}
|
||||
if (destinations.size() != 1) throw conflict("目标托盘内货物与本次换标的新目的仓不同,不能合托");
|
||||
}
|
||||
|
||||
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
|
||||
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
|
||||
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
|
||||
@@ -658,6 +853,7 @@ public class PdaPalletOperationService {
|
||||
List<PlannedMove> moves, List<PalletChange> changes,
|
||||
RelabelMutation relabelMutation) { }
|
||||
private record RelabelMutation(RelabelApplication application,
|
||||
Map<Long, List<RelabelItem>> itemsByPosition) { }
|
||||
Map<Long, List<RelabelItem>> itemsByPosition,
|
||||
Map<Long, InventoryPosition> anchors) { }
|
||||
private enum InventoryBucket { AVAILABLE, HOLD }
|
||||
}
|
||||
|
||||
+10
@@ -36,6 +36,16 @@ public class RelabelApplicationController {
|
||||
return ApiResponse.success(pdaService.returnForRework(operator(user), user.username(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/confirm-completion")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')")
|
||||
public ApiResponse<RelabelResponses.Detail> confirmCompletion(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String relabelNo,
|
||||
@Valid @RequestBody RelabelRequests.CustomerConfirm request) {
|
||||
requirePlatform(user);
|
||||
String customerId = service.customerId(relabelNo);
|
||||
return ApiResponse.success(service.confirmCompletion(customerId, relabelNo, operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}/review-task")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
|
||||
public ApiResponse<com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses.Detail> reviewTask(
|
||||
|
||||
@@ -39,9 +39,9 @@ public final class RelabelRequests {
|
||||
}
|
||||
|
||||
public record Item(
|
||||
@NotNull Long sourceInventoryBalanceId,
|
||||
@NotNull Long sourceInventoryPositionId,
|
||||
@NotNull Long sourceInboundOrderId,
|
||||
Long sourceInventoryBalanceId,
|
||||
Long sourceInventoryPositionId,
|
||||
Long sourceInboundOrderId,
|
||||
@Size(max = 128) String skuCode,
|
||||
@Size(max = 128) String productImageFileId,
|
||||
@Size(max = 255) String productImageFileName,
|
||||
@@ -61,8 +61,22 @@ public final class RelabelRequests {
|
||||
@Size(max = 255) String fbaCartonMark,
|
||||
@Size(max = 128) String destinationFbaWarehouseCode,
|
||||
@Size(max = 32) String operationType,
|
||||
@Size(max = 100) List<@Valid SkuItem> skuItems
|
||||
@Size(max = 100) List<@Valid SkuItem> skuItems,
|
||||
Long returnCartonId
|
||||
) {
|
||||
public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
|
||||
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
|
||||
String productNameZh, int totalPackageCount, int notifiedPackageCount, String shipmentNo,
|
||||
String trackingCode, String newSkuCode, String labelRequirement, String remark,
|
||||
String relabelFileId, String relabelFileName, String relabelFileUrl, String cartonMark,
|
||||
String fbaCartonMark, String destinationFbaWarehouseCode, String operationType,
|
||||
List<SkuItem> skuItems) {
|
||||
this(sourceInventoryBalanceId, sourceInventoryPositionId, sourceInboundOrderId, skuCode,
|
||||
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
|
||||
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
|
||||
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
|
||||
destinationFbaWarehouseCode, operationType, skuItems, null);
|
||||
}
|
||||
/** 保持既有箱唛换标调用方的二进制参数形态。 */
|
||||
public Item(Long sourceInventoryBalanceId, Long sourceInventoryPositionId, Long sourceInboundOrderId,
|
||||
String skuCode, String productImageFileId, String productImageFileName, String productImageUrl,
|
||||
@@ -74,7 +88,7 @@ public final class RelabelRequests {
|
||||
productImageFileId, productImageFileName, productImageUrl, productNameZh, totalPackageCount,
|
||||
notifiedPackageCount, shipmentNo, trackingCode, newSkuCode, labelRequirement, remark,
|
||||
relabelFileId, relabelFileName, relabelFileUrl, cartonMark, fbaCartonMark,
|
||||
destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
|
||||
destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null);
|
||||
}
|
||||
public Item(String skuCode, String productImageFileId, String productImageFileName,
|
||||
String productImageUrl, String productNameZh, int totalPackageCount,
|
||||
@@ -85,7 +99,7 @@ public final class RelabelRequests {
|
||||
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl,
|
||||
productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode,
|
||||
newSkuCode, labelRequirement, remark, relabelFileId, relabelFileName, relabelFileUrl,
|
||||
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of());
|
||||
cartonMark, fbaCartonMark, destinationFbaWarehouseCode, "CARTON_LABEL", List.of(), null);
|
||||
}
|
||||
public Item(String skuCode, String productImageFileId, String productImageFileName,
|
||||
String productImageUrl, String productNameZh, int totalPackageCount,
|
||||
@@ -94,7 +108,7 @@ public final class RelabelRequests {
|
||||
this(101L, 201L, 301L, skuCode, productImageFileId, productImageFileName, productImageUrl, productNameZh,
|
||||
totalPackageCount, notifiedPackageCount, shipmentNo, trackingCode, newSkuCode,
|
||||
labelRequirement, null, relabelFileId, relabelFileName, relabelFileUrl, skuCode, shipmentNo, "ONT8",
|
||||
"CARTON_LABEL", List.of());
|
||||
"CARTON_LABEL", List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ public final class RelabelResponses {
|
||||
String inboundCode, String overseasWarehouseId, String overseasWarehouseCode,
|
||||
String overseasWarehouseName, String destinationFbaWarehouseCode,
|
||||
String destinationFbaWarehouseName, String skuCode, String productNameZh,
|
||||
long availablePackages, long version, List<InventoryPosition> positions) {}
|
||||
long availablePackages, long version, List<InventoryPosition> positions,
|
||||
String sourceType, Long returnCartonId, String cartonMark,
|
||||
List<SkuItem> skuItems) {}
|
||||
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
|
||||
String relabelOrderNo, String orderType, String status, Integer packageCount,
|
||||
LocalDateTime createdAt) {}
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ public class HttpRelabelDocumentFileGateway implements RelabelDocumentFileGatewa
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Resource private LastlegRestClientFactory restClientFactory;
|
||||
@Resource private LastlegRestClientProperties restClientProperties;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
|
||||
+10
@@ -6,4 +6,14 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
|
||||
@org.apache.ibatis.annotations.Select("SELECT id FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} FOR UPDATE")
|
||||
Long lockSourceBalance(@org.apache.ibatis.annotations.Param("customerId") String customerId,
|
||||
@org.apache.ibatis.annotations.Param("balanceId") Long balanceId);
|
||||
|
||||
@org.apache.ibatis.annotations.Select("SELECT a.relabel_no FROM ll_relabel_item i JOIN ll_relabel_application a ON a.id=i.application_id " +
|
||||
"WHERE i.customer_id=#{customerId} AND a.customer_id=#{customerId} AND i.source_inventory_balance_id=#{balanceId} " +
|
||||
"AND UPPER(TRIM(i.carton_mark))=UPPER(TRIM(#{mark})) AND a.status NOT IN ('CANCELLED','REJECTED') LIMIT 1")
|
||||
String findExistingCarton(@org.apache.ibatis.annotations.Param("customerId") String customerId,
|
||||
@org.apache.ibatis.annotations.Param("balanceId") Long balanceId,
|
||||
@org.apache.ibatis.annotations.Param("mark") String mark);
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.relabel.model;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** 换标申请业务状态。 */
|
||||
public enum RelabelApplicationStatus {
|
||||
PENDING_PROCESSING("待处理"),
|
||||
PROCESSING("处理中"),
|
||||
CUSTOMER_CONFIRM_PENDING("待客户确认"),
|
||||
READY_FOR_OUTBOUND("换标完成,待出库"),
|
||||
COMPLETED("已完成"),
|
||||
REJECTED("已拒绝"),
|
||||
CANCELLED("已取消");
|
||||
|
||||
private final String label;
|
||||
|
||||
RelabelApplicationStatus(String label) { this.label = label; }
|
||||
|
||||
public String code() { return name(); }
|
||||
public String label() { return label; }
|
||||
|
||||
public static RelabelApplicationStatus fromCode(String code) {
|
||||
return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unsupported relabel application status: " + code));
|
||||
}
|
||||
}
|
||||
+8
@@ -70,6 +70,14 @@ public class PdaRelabelController {
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{relabelNo}/skus/scan")
|
||||
public ApiResponse<PdaRelabelResponses.Detail> scanSku(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.SkuScan request) {
|
||||
Identity identity = identity(user);
|
||||
return ApiResponse.success(service.scanSku(identity.operatorKey(), identity.operatorName(),
|
||||
identity.overseasWarehouseId(), relabelNo, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{relabelNo}/documents")
|
||||
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String relabelNo) {
|
||||
|
||||
+4
@@ -14,6 +14,10 @@ public final class PdaRelabelRequests {
|
||||
private PdaRelabelRequests() { }
|
||||
|
||||
public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record SkuScan(@NotNull @Positive Long itemId, @Min(0) int skuIndex,
|
||||
@NotBlank @Size(max = 255) String oldSku,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record CartonScan(@NotBlank @Size(max = 255) String cartonMark,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ public final class PdaRelabelResponses {
|
||||
String cartonMark, String fbaCartonMark, String operationType,
|
||||
List<SkuItem> skuItems,
|
||||
int expectedPackages, int relabelledPackages, int skippedPackages,
|
||||
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages) { }
|
||||
int exceptionPackages, int cartonProcessedPackages, int skuProcessedPackages,
|
||||
String destinationFbaWarehouseCode) { }
|
||||
public record SkuItem(String skuCode, String newSkuCode, int quantity, String remark) { }
|
||||
|
||||
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
|
||||
|
||||
+12
-2
@@ -15,8 +15,18 @@ public interface PdaRelabelTaskMapper extends BaseMapper<PdaRelabelTask> {
|
||||
@Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE")
|
||||
PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId);
|
||||
|
||||
@Select("SELECT * FROM ll_relabel_pda_task WHERE operator_key=#{operatorKey} AND status<>'COMPLETED' ORDER BY claimed_at ASC LIMIT 1 FOR UPDATE")
|
||||
PdaRelabelTask lockUnfinishedByOperator(@Param("operatorKey") String operatorKey);
|
||||
@Select("""
|
||||
SELECT task.*
|
||||
FROM ll_relabel_pda_task task
|
||||
JOIN ll_relabel_application application
|
||||
ON application.id=task.application_id AND application.customer_id=task.customer_id
|
||||
WHERE task.operator_key=#{operatorKey}
|
||||
AND task.status='PROCESSING'
|
||||
AND application.status='PROCESSING'
|
||||
ORDER BY task.claimed_at ASC
|
||||
LIMIT 1 FOR UPDATE
|
||||
""")
|
||||
PdaRelabelTask lockProcessingByOperator(@Param("operatorKey") String operatorKey);
|
||||
|
||||
@Update("""
|
||||
UPDATE ll_relabel_pda_task
|
||||
|
||||
+57
-4
@@ -51,6 +51,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class PdaRelabelService {
|
||||
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard bindingGuard;
|
||||
public static final String PROCESSING = "PROCESSING";
|
||||
public static final String WAIT_REVIEW = "WAIT_REVIEW";
|
||||
public static final String COMPLETED = "COMPLETED";
|
||||
@@ -109,7 +110,7 @@ public class PdaRelabelService {
|
||||
throw conflict("Relabel application is not executable");
|
||||
}
|
||||
PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId());
|
||||
PdaRelabelTask activeTask = tasks.lockUnfinishedByOperator(operatorKey);
|
||||
PdaRelabelTask activeTask = tasks.lockProcessingByOperator(operatorKey);
|
||||
if (activeTask != null && !Objects.equals(activeTask.getApplicationId(), application.getId())) {
|
||||
throw conflict("当前账号正在执行换标单 " + activeTask.getRelabelNo() + ",请先完成后再操作其他换标单");
|
||||
}
|
||||
@@ -194,6 +195,49 @@ public class PdaRelabelService {
|
||||
|
||||
public record Document(String scope, String fileId, String fileName, Long itemId) { }
|
||||
|
||||
@Transactional
|
||||
public PdaRelabelResponses.Detail scanSku(String operatorKey, String operatorName, long warehouseId,
|
||||
String relabelNo, PdaRelabelRequests.SkuScan request) {
|
||||
UnitCommand command = new UnitCommand(relabelNo, "SCAN_SKU", request);
|
||||
var replayed = replay(operatorKey, warehouseId, relabelNo, "SCAN_SKU", request.idempotencyKey(), command);
|
||||
if (replayed != null) return replayed;
|
||||
var app = requireApplication(relabelNo, warehouseId, true);
|
||||
var task = requireOwnedTask(app, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
var item = requireItem(app, request.itemId());
|
||||
if (!Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item))) throw conflict("该箱没有SKU换标任务");
|
||||
if (value(item.getNotifiedPackageCount()) != 1) throw conflict("SKU逐条作业要求每个仓库唛头独立一箱");
|
||||
var skus = readSkuItems(item.getSkuRelabelItemsJson());
|
||||
if (request.skuIndex() < 0 || request.skuIndex() >= skus.size()) throw conflict("SKU明细不存在");
|
||||
var sku = skus.get(request.skuIndex());
|
||||
if (!Objects.equals(sku.skuCode(), request.oldSku().trim()) || !StringUtils.hasText(sku.newSkuCode()) || sku.quantity() <= 0)
|
||||
throw conflict("旧SKU或绑定的新标签、数量不正确");
|
||||
if (factList(task).stream().anyMatch(fact -> item.getId().equals(fact.getItemId()))
|
||||
|| mixedStageDone(task, item.getId(), "SKU")) throw conflict("本箱SKU作业已完成");
|
||||
Set<Integer> done = new java.util.HashSet<>();
|
||||
for (var history : histories.selectList(new LambdaQueryWrapper<PdaRelabelHistory>()
|
||||
.eq(PdaRelabelHistory::getTaskId, task.getId()).eq(PdaRelabelHistory::getCustomerId, app.getCustomerId())
|
||||
.eq(PdaRelabelHistory::getAction, "SKU_SCAN"))) {
|
||||
if (!"SKU_SCAN".equals(history.getAction())) continue;
|
||||
try {
|
||||
var data = objectMapper.readTree(history.getDetailJson());
|
||||
if (mixedRound(task).equals(data.path("round").asText()) && item.getId().toString().equals(data.path("itemId").asText()))
|
||||
done.add(data.path("skuIndex").asInt(-1));
|
||||
} catch (Exception failure) { throw conflict("SKU扫描历史无法读取"); }
|
||||
}
|
||||
if (!done.add(request.skuIndex())) throw conflict("该SKU明细已处理,请勿重复扫描");
|
||||
insertHistory(task, "SKU_SCAN", PROCESSING, PROCESSING, operatorKey, operatorName,
|
||||
Map.of("round", mixedRound(task), "itemId", item.getId().toString(), "skuIndex", request.skuIndex(),
|
||||
"oldSku", sku.skuCode(), "newSku", sku.newSkuCode(), "quantity", sku.quantity()));
|
||||
if (done.size() == skus.size()) return unitAction(operatorKey, operatorName, warehouseId, relabelNo,
|
||||
"SCAN_SKU", request.idempotencyKey(), request, item.getId(), item.getCartonMark(), null,
|
||||
"RELABELLED", null, null, request.expectedVersion(), ignored -> { });
|
||||
updateTask(task, request.expectedVersion());
|
||||
var result = detail(app, task);
|
||||
saveOperation(task, operatorKey, "SCAN_SKU", request.idempotencyKey(), command, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Document> documents(long warehouseId, String relabelNo) {
|
||||
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
|
||||
@@ -264,6 +308,7 @@ public class PdaRelabelService {
|
||||
RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true);
|
||||
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
|
||||
requireVersion(task, request.expectedVersion());
|
||||
if (!request.manualCompletion()) bindingGuard.requireBound(application);
|
||||
List<RelabelItem> detailItems = itemList(application);
|
||||
List<PdaRelabelUnitFact> facts = factList(task);
|
||||
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
|
||||
@@ -558,7 +603,7 @@ public class PdaRelabelService {
|
||||
private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task,
|
||||
String operatorKey) {
|
||||
return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
application.getLabelDeadline(), application.getStatus(), effectiveTaskStatus(application, task),
|
||||
operationType(application), value(application.getItemCount()),
|
||||
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
@@ -580,7 +625,8 @@ public class PdaRelabelService {
|
||||
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
|
||||
count(itemFacts, "EXCEPTION"),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0));
|
||||
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
|
||||
item.getDestinationFbaWarehouseCode());
|
||||
}).toList();
|
||||
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
|
||||
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
|
||||
@@ -593,7 +639,7 @@ public class PdaRelabelService {
|
||||
history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList();
|
||||
return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(),
|
||||
application.getLabelDeadline(), application.getContactName(), application.getContactPhone(),
|
||||
application.getStatus(), operationType(application), task == null ? "AVAILABLE" : task.getStatus(),
|
||||
application.getStatus(), operationType(application), effectiveTaskStatus(application, task),
|
||||
task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()),
|
||||
task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()),
|
||||
task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()),
|
||||
@@ -605,6 +651,13 @@ public class PdaRelabelService {
|
||||
itemResponses, factResponses, historyResponses);
|
||||
}
|
||||
|
||||
private String effectiveTaskStatus(RelabelApplication application, PdaRelabelTask task) {
|
||||
if (task == null) return "AVAILABLE";
|
||||
if (RelabelApplicationService.CUSTOMER_CONFIRM_PENDING.equals(application.getStatus())
|
||||
&& PROCESSING.equals(task.getStatus())) return WAIT_REVIEW;
|
||||
return task.getStatus();
|
||||
}
|
||||
|
||||
private void insertHistory(PdaRelabelTask task, String action, String from, String to,
|
||||
String operatorKey, String operatorName, Object detail) {
|
||||
PdaRelabelHistory history = new PdaRelabelHistory();
|
||||
|
||||
+178
-44
@@ -29,6 +29,10 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
|
||||
@@ -84,6 +88,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
@Resource private InventoryCommandService inventoryCommands;
|
||||
@Resource private InventoryBalanceMapper inventoryBalances;
|
||||
@Resource private InventoryPositionMapper inventoryPositions;
|
||||
@Resource private ReturnInventoryCartonMapper returnCartons;
|
||||
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
|
||||
@Resource private RelabelDocumentFileGateway documentFiles;
|
||||
@Resource private PdaRelabelTaskMapper pdaTasks;
|
||||
@Resource private RelabelPhotoFileGateway photoFiles;
|
||||
@@ -238,7 +244,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
.gt(InventoryBalance::getAvailablePackages, 0)
|
||||
.like(StringUtils.hasText(inboundCode), InventoryBalance::getInboundCode, trim(inboundCode))
|
||||
.orderByDesc(InventoryBalance::getUpdatedAt));
|
||||
return balances.stream().map(balance -> {
|
||||
List<RelabelResponses.InventoryCandidate> result = new java.util.ArrayList<>(balances.stream().map(balance -> {
|
||||
List<RelabelResponses.InventoryPosition> positions = inventoryPositions.listBalance(customerId, balance.getId())
|
||||
.stream().filter(position -> value(position.getAvailablePackages()) > 0)
|
||||
.map(position -> new RelabelResponses.InventoryPosition(position.getId(), position.getPalletNo(),
|
||||
@@ -249,8 +255,27 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
balance.getInboundOrderNo(), balance.getInboundCode(), balance.getOverseasWarehouseId(),
|
||||
balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(),
|
||||
balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(),
|
||||
balance.getProductNameZh(), available, value(balance.getVersion()), positions);
|
||||
}).filter(candidate -> candidate.availablePackages() > 0).toList();
|
||||
balance.getProductNameZh(), available, value(balance.getVersion()), positions,
|
||||
"INBOUND", null, null, List.of());
|
||||
}).filter(candidate -> candidate.availablePackages() > 0).toList());
|
||||
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) {
|
||||
String keyword = trim(inboundCode);
|
||||
if (StringUtils.hasText(keyword)
|
||||
&& !containsIgnoreCase(carton.getCartonMark(), keyword)
|
||||
&& !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue;
|
||||
List<RelabelResponses.SkuItem> cartonSkus = returnCartonItems.listCarton(carton.getId()).stream()
|
||||
.filter(item -> value(item.getQuantity()) > 0)
|
||||
.map(item -> new RelabelResponses.SkuItem(item.getSkuCode(), null,
|
||||
Math.toIntExact(item.getQuantity()), null)).toList();
|
||||
if (cartonSkus.isEmpty()) continue;
|
||||
String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode)
|
||||
.collect(Collectors.joining(","));
|
||||
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
|
||||
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
|
||||
carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(),
|
||||
"RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -280,6 +305,19 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
@Transactional
|
||||
public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) {
|
||||
return create(customerId, operatorId, request, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RelabelResponses.Detail createForExistingTailOrder(String customerId, String operatorId,
|
||||
Long existingTailOrderId,
|
||||
RelabelRequests.Create request) {
|
||||
if (existingTailOrderId == null) throw badRequest("Existing tail order is required");
|
||||
return create(customerId, operatorId, request, existingTailOrderId);
|
||||
}
|
||||
|
||||
private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request,
|
||||
Long existingTailOrderId) {
|
||||
if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required");
|
||||
boolean requiresOutbound = request.items().stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
|
||||
String deliveryMethod = trim(request.deliveryMethod());
|
||||
@@ -298,6 +336,13 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data");
|
||||
return detail(customerId, replay.getRelabelNo());
|
||||
}
|
||||
Set<String> requestedDestinations = request.items().stream()
|
||||
.filter(item -> !"SKU_LABEL".equals(operationType(item)))
|
||||
.map(RelabelRequests.Item::destinationFbaWarehouseCode)
|
||||
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet());
|
||||
if (requestedDestinations.size() > 1) {
|
||||
throw badRequest("同一张贴标申请只能选择一个 FBA 目的仓");
|
||||
}
|
||||
int total = 0;
|
||||
int notified = 0;
|
||||
List<SourceInventory> sources = new java.util.ArrayList<>();
|
||||
@@ -312,6 +357,25 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (!selectedCartonMarks.add(item.cartonMark().trim().toUpperCase())) {
|
||||
throw badRequest("The same carton mark cannot create more than one relabel task");
|
||||
}
|
||||
if (item.returnCartonId() != null) {
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId());
|
||||
if (carton == null || !customerId.equals(carton.getCustomerId())
|
||||
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
|
||||
throw conflict("所选退货箱不属于当前客户和操作仓");
|
||||
}
|
||||
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
|
||||
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
|
||||
}
|
||||
if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) {
|
||||
throw conflict("退货箱仓库唛头已变化,请重新查询");
|
||||
}
|
||||
sources.add(new SourceInventory(item, null, carton));
|
||||
continue;
|
||||
}
|
||||
if (item.sourceInventoryBalanceId() == null || item.sourceInventoryPositionId() == null
|
||||
|| item.sourceInboundOrderId() == null) {
|
||||
throw badRequest("正常入仓库存必须提供余额、仓位和入仓单");
|
||||
}
|
||||
InventoryPosition position = inventoryPositions.findTenantPosition(customerId, item.sourceInventoryPositionId());
|
||||
if (position == null || !Objects.equals(position.getBalanceId(), item.sourceInventoryBalanceId())) {
|
||||
throw conflict("Selected inventory position is no longer available");
|
||||
@@ -325,7 +389,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) {
|
||||
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
|
||||
}
|
||||
sources.add(new SourceInventory(item, balance));
|
||||
sources.add(new SourceInventory(item, balance, null));
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
long applicationId = IdWorker.getId();
|
||||
@@ -335,13 +399,15 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
boolean requiresWallet = chargeAmount.signum() > 0;
|
||||
int holdIndex = 0;
|
||||
for (SourceInventory source : sources) {
|
||||
if (source.returnCarton() != null) continue;
|
||||
inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo,
|
||||
null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++,
|
||||
"贴标申请冻结原入仓单库存"));
|
||||
}
|
||||
LastlegOrder tailOrder = insertTailOrder(customerId, request, requestHash, relabelNo,
|
||||
operationWarehouseId, total, now);
|
||||
LastlegOrder tailOrder = existingTailOrderId == null
|
||||
? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now)
|
||||
: linkExistingTailOrder(customerId, existingTailOrderId, relabelNo, total, now);
|
||||
List<LastlegOrder> tailOrders = List.of(tailOrder);
|
||||
|
||||
RelabelApplication application = new RelabelApplication();
|
||||
@@ -372,6 +438,14 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
application.setUpdatedAt(now);
|
||||
applications.insert(application);
|
||||
|
||||
for (SourceInventory source : sources) {
|
||||
if (source.returnCarton() == null) continue;
|
||||
ReturnInventoryCarton carton = source.returnCarton();
|
||||
carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo);
|
||||
carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
|
||||
returnCartons.updateById(carton);
|
||||
}
|
||||
|
||||
if (requiresWallet) {
|
||||
String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER",
|
||||
relabelNo, chargeAmount, "换标订单费用冻结", 1L);
|
||||
@@ -383,8 +457,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
insertItem(customerId, applicationId, source, now);
|
||||
}
|
||||
insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now);
|
||||
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
|
||||
order.getCurrentStatus(), "Relabel application submitted", now));
|
||||
if (existingTailOrderId == null) {
|
||||
tailOrders.forEach(order -> insertOrderHistory(customerId, order.getId(), null,
|
||||
order.getCurrentStatus(), "Relabel application submitted", now));
|
||||
}
|
||||
return detail(customerId, relabelNo);
|
||||
}
|
||||
|
||||
@@ -501,7 +577,8 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
}
|
||||
for (LastlegOrder order : linkedOrders) {
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore;
|
||||
String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType())
|
||||
? "WAIT_RELABEL" : orderBefore;
|
||||
String orderStatus = switch (next) {
|
||||
case CUSTOMER_CONFIRM_PENDING -> mainStatus;
|
||||
case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType())
|
||||
@@ -606,23 +683,27 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
PdaRelabelTask reviewTask = pdaTasks.lock(customerId, application.getId());
|
||||
if (reviewTask == null || !Set.of("WAIT_REVIEW", "COMPLETED").contains(reviewTask.getStatus()))
|
||||
throw conflict("仓库尚未提交换标完工复核");
|
||||
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CUSTOMER_CONFIRMED");
|
||||
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
|
||||
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
|
||||
reviewTask.setUpdatedAt(LocalDateTime.now());
|
||||
if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新");
|
||||
if (Boolean.TRUE.equals(application.getPrintShippingLabel())
|
||||
&& !StringUtils.hasText(application.getShippingLabelFileId())) {
|
||||
throw conflict("Upload the shipping label before confirming relabel completion");
|
||||
}
|
||||
List<RelabelItem> relabelItems = itemList(application.getId());
|
||||
boolean requiresOutbound = relabelItems.stream()
|
||||
.anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
|
||||
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
|
||||
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
|
||||
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
|
||||
reviewTask.setUpdatedAt(LocalDateTime.now());
|
||||
if (pdaTasks.updateWithVersion(reviewTask, reviewTask.getVersion()) != 1) throw conflict("复核状态已变化,请刷新");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String before = application.getStatus();
|
||||
application.setStatus(READY_FOR_OUTBOUND);
|
||||
application.setStatus(requiresOutbound ? READY_FOR_OUTBOUND : "COMPLETED");
|
||||
application.setCustomerConfirmationKey(key);
|
||||
application.setCustomerConfirmedAt(now);
|
||||
application.setFulfillmentStatus("TRUCK".equals(application.getDeliveryMethod())
|
||||
? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION");
|
||||
if (!"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
application.setFulfillmentStatus(requiresOutbound
|
||||
? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION")
|
||||
: "NOT_REQUIRED");
|
||||
if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) {
|
||||
createDirectOutbound(application, operatorId, now);
|
||||
application.setFulfillmentStatus("OUTBOUND_CREATED");
|
||||
}
|
||||
@@ -630,7 +711,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
applications.updateById(application);
|
||||
for (LastlegOrder order : tailOrders(application)) {
|
||||
String orderBefore = order.getCurrentStatus();
|
||||
order.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED");
|
||||
order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus());
|
||||
order.setRelabelStatus("COMPLETED");
|
||||
order.setUpdatedAt(now);
|
||||
@@ -640,24 +721,34 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
request.remark(), now);
|
||||
}
|
||||
}
|
||||
insertHistory(customerId, application.getId(), before, READY_FOR_OUTBOUND, operatorId,
|
||||
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
|
||||
request.remark(), now);
|
||||
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
|
||||
if (warehouseTransfers != null) {
|
||||
WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo);
|
||||
if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) {
|
||||
transfer.setStatus("READY_FOR_DISPATCH");
|
||||
Set<String> destinations = relabelItems.stream()
|
||||
.map(RelabelItem::getDestinationFbaWarehouseCode)
|
||||
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet());
|
||||
if (!StringUtils.hasText(transfer.getFbaWarehouseCode()) && destinations.size() == 1) {
|
||||
transfer.setFbaWarehouseCode(destinations.iterator().next());
|
||||
}
|
||||
String transferStatus = StringUtils.hasText(transfer.getFbaWarehouseCode())
|
||||
? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION";
|
||||
transfer.setStatus(transferStatus);
|
||||
transfer.setRelabelCompletedAt(now);
|
||||
transfer.setUpdatedAt(now);
|
||||
warehouseTransfers.updateById(transfer);
|
||||
application.setFulfillmentStatus(transferStatus);
|
||||
applications.updateById(application);
|
||||
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
|
||||
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
|
||||
if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) {
|
||||
String transferOrderBefore = transferOrder.getCurrentStatus();
|
||||
transferOrder.setCurrentStatus("READY_FOR_DISPATCH");
|
||||
transferOrder.setCurrentStatus(transferStatus);
|
||||
transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode());
|
||||
transferOrder.setRelabelStatus("COMPLETED");
|
||||
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:READY_FOR_DISPATCH;RELABEL:"
|
||||
+ "COMPLETED");
|
||||
transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED");
|
||||
transferOrder.setUpdatedAt(now);
|
||||
orders.updateById(transferOrder);
|
||||
if (!Objects.equals(transferOrderBefore, transferOrder.getCurrentStatus())) {
|
||||
@@ -683,27 +774,28 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
|
||||
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND");
|
||||
outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum());
|
||||
outbound.setPalletCount((int) relabelItems.stream().map(RelabelItem::getSourceInventoryPositionId).filter(Objects::nonNull).distinct().count());
|
||||
outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count());
|
||||
outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key);
|
||||
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound);
|
||||
Map<Long, List<RelabelItem>> byPosition = relabelItems.stream().collect(Collectors.groupingBy(
|
||||
RelabelItem::getSourceInventoryPositionId, LinkedHashMap::new, Collectors.toList()));
|
||||
this::outboundSourceKey, LinkedHashMap::new, Collectors.toList()));
|
||||
for (Map.Entry<Long, List<RelabelItem>> entry : byPosition.entrySet()) {
|
||||
InventoryPosition position = inventoryPositions.selectById(entry.getKey());
|
||||
if (position == null || !application.getCustomerId().equals(position.getCustomerId())) {
|
||||
InventoryPosition position = entry.getKey() > 0 ? inventoryPositions.selectById(entry.getKey()) : null;
|
||||
if (entry.getKey() > 0 && (position == null || !application.getCustomerId().equals(position.getCustomerId()))) {
|
||||
throw conflict("Relabel source inventory position no longer exists");
|
||||
}
|
||||
List<RelabelItem> group = entry.getValue();
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId());
|
||||
item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId());
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo());
|
||||
item.setWarehousePalletId(position == null ? null : position.getPalletId());
|
||||
item.setPalletNo(position == null ? null : position.getPalletNo());
|
||||
item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum());
|
||||
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK");
|
||||
item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode)
|
||||
.filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(",")));
|
||||
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), entry.getKey(), group));
|
||||
item.setOrderRefs(writeOutboundRefs(application.getRelabelNo(), position == null ? null : entry.getKey(), group));
|
||||
item.setCreatedAt(now); outboundItems.insert(item);
|
||||
}
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId());
|
||||
@@ -714,11 +806,14 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
|
||||
private String writeOutboundRefs(String relabelNo, Long positionId, List<RelabelItem> values) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of("sourceType", "RELABEL_ORDER", "relabelNo", relabelNo,
|
||||
"sourceInventoryPositionId", positionId, "items", values.stream().map(item -> Map.of(
|
||||
Map<String, Object> refs = new LinkedHashMap<>();
|
||||
refs.put("sourceType", "RELABEL_ORDER"); refs.put("relabelNo", relabelNo);
|
||||
if (positionId != null) refs.put("sourceInventoryPositionId", positionId);
|
||||
refs.put("items", values.stream().map(item -> Map.of(
|
||||
"itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
|
||||
"destination", Objects.toString(item.getDestinationFbaWarehouseCode(), ""),
|
||||
"packages", intValue(item.getNotifiedPackageCount()))).toList()));
|
||||
"packages", intValue(item.getNotifiedPackageCount()))).toList());
|
||||
return objectMapper.writeValueAsString(refs);
|
||||
} catch (Exception exception) { throw new IllegalStateException("Cannot save relabel outbound snapshot", exception); }
|
||||
}
|
||||
|
||||
@@ -740,14 +835,21 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
item.setDestinationFbaWarehouseCode(trim(source.destinationFbaWarehouseCode()));
|
||||
item.setOperationType(operationType(source));
|
||||
item.setSkuRelabelItemsJson(writeSkuItems(source.skuItems()));
|
||||
item.setSourceInventoryBalanceId(balance.getId());
|
||||
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
|
||||
item.setSourceInboundOrderId(balance.getInboundOrderId());
|
||||
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
|
||||
item.setSourceInboundCode(balance.getInboundCode());
|
||||
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
|
||||
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
|
||||
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
|
||||
if (balance != null) {
|
||||
item.setSourceInventoryBalanceId(balance.getId());
|
||||
item.setSourceInventoryPositionId(source.sourceInventoryPositionId());
|
||||
item.setSourceInboundOrderId(balance.getInboundOrderId());
|
||||
item.setSourceInboundOrderNo(balance.getInboundOrderNo());
|
||||
item.setSourceInboundCode(balance.getInboundCode());
|
||||
item.setSourceWarehouseId(balance.getOverseasWarehouseId());
|
||||
item.setSourceWarehouseCode(balance.getOverseasWarehouseCode());
|
||||
item.setSourceWarehouseName(balance.getOverseasWarehouseName());
|
||||
} else {
|
||||
ReturnInventoryCarton carton = sourceInventory.returnCarton();
|
||||
item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark());
|
||||
item.setSourceInboundOrderNo(carton.getSourceReturnNo());
|
||||
item.setSourceWarehouseId(carton.getOverseasWarehouseId());
|
||||
}
|
||||
item.setCreatedAt(now); items.insert(item);
|
||||
}
|
||||
|
||||
@@ -831,7 +933,7 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
order.setSourceInboundId(relabelNo + ":" + warehouseId);
|
||||
order.setSourceOrderNo(request.customerOrderNo().trim()); order.setOverseasWarehouseId(warehouseId);
|
||||
order.setPalletCount(0); order.setPackageCount(packageCount); order.setPickedPackageCount(0);
|
||||
order.setShippedPackageCount(0); order.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
order.setShippedPackageCount(0); order.setCurrentStatus("WAIT_RELABEL");
|
||||
order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); order.setRelabelOrderNo(relabelNo);
|
||||
order.setRelabelStatus(PENDING_PROCESSING); order.setCustomerRemark(trim(request.remark()));
|
||||
order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo + ":" + warehouseId);
|
||||
@@ -840,6 +942,17 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
return order;
|
||||
}
|
||||
|
||||
private LastlegOrder linkExistingTailOrder(String customerId, Long orderId, String relabelNo,
|
||||
int packageCount, LocalDateTime now) {
|
||||
LastlegOrder order = orders.selectById(orderId);
|
||||
if (order == null || !customerId.equals(order.getCustomerId())) {
|
||||
throw conflict("Existing tail order does not belong to this customer");
|
||||
}
|
||||
order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(PENDING_PROCESSING);
|
||||
order.setPackageCount(packageCount); order.setUpdatedAt(now); orders.updateById(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
private List<LastlegOrder> tailOrders(RelabelApplication application) {
|
||||
List<LastlegOrder> linked = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, application.getCustomerId())
|
||||
@@ -850,12 +963,27 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
return legacy == null ? List.of() : List.of(legacy);
|
||||
}
|
||||
|
||||
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance) {}
|
||||
private record SourceInventory(RelabelRequests.Item request, InventoryBalance balance,
|
||||
ReturnInventoryCarton returnCarton) {}
|
||||
private static long value(Long value) { return value == null ? 0L : value; }
|
||||
private static int intValue(Integer value) { return value == null ? 0 : value; }
|
||||
private static boolean containsIgnoreCase(String value, String fragment) {
|
||||
return value != null && fragment != null
|
||||
&& value.toLowerCase(java.util.Locale.ROOT).contains(fragment.toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
|
||||
private void releaseInventory(String customerId, String operatorId, RelabelApplication application) {
|
||||
for (RelabelItem item : itemList(application.getId())) {
|
||||
if (item.getReturnCartonId() != null) {
|
||||
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
|
||||
if (carton != null && customerId.equals(carton.getCustomerId())
|
||||
&& Objects.equals(application.getId(), carton.getRelabelApplicationId())
|
||||
&& "RELABEL_PENDING".equals(carton.getStatus())) {
|
||||
carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setRelabelStatus(null);
|
||||
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(LocalDateTime.now()); returnCartons.updateById(carton);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue;
|
||||
inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move(
|
||||
item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null,
|
||||
@@ -938,9 +1066,15 @@ public class RelabelApplicationService implements WalletResultHandler {
|
||||
|
||||
private static String businessRelabelStatus(String workflowStatus) {
|
||||
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
|
||||
return Set.of(READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
||||
return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
|
||||
? "COMPLETED" : PENDING_PROCESSING;
|
||||
}
|
||||
|
||||
private long outboundSourceKey(RelabelItem item) {
|
||||
if (item.getSourceInventoryPositionId() != null) return item.getSourceInventoryPositionId();
|
||||
if (item.getReturnCartonId() != null) return -item.getReturnCartonId();
|
||||
throw conflict("Relabel outbound item has no physical inventory source");
|
||||
}
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.lastleg.relabel.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
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.*;
|
||||
|
||||
/** Requires saved physical bindings, never the original accounting freeze anchor. */
|
||||
@Service
|
||||
public class RelabelBindingGuard {
|
||||
@Resource private RelabelItemMapper items;
|
||||
@Resource private PdaRelabelHistoryMapper histories;
|
||||
@Resource private InventoryPositionMapper positions;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public void requireBound(RelabelApplication app) {
|
||||
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
|
||||
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
|
||||
.stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList();
|
||||
if (cartons.isEmpty()) return;
|
||||
Map<String, Set<String>> bindings = new HashMap<>();
|
||||
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
|
||||
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId())
|
||||
.orderByAsc("created_at", "id"))) {
|
||||
if ("REWORK_ARCHIVE".equals(history.getAction())) bindings.clear();
|
||||
if (!"PALLET_BIND".equals(history.getAction())) continue;
|
||||
try {
|
||||
var data = objectMapper.readTree(history.getDetailJson());
|
||||
Set<String> targets = new HashSet<>();
|
||||
data.path("targetPallets").forEach(value -> targets.add(value.asText()));
|
||||
data.path("cartonMarks").forEach(value -> bindings.put(value.asText(), targets));
|
||||
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
|
||||
}
|
||||
for (RelabelItem item : cartons) {
|
||||
var position = item.getSourceInventoryPositionId() == null ? null
|
||||
: positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId());
|
||||
if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
|
||||
|| position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount())
|
||||
throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库");
|
||||
}
|
||||
}
|
||||
private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
}
|
||||
@@ -569,7 +569,7 @@ public class LastlegOrderService {
|
||||
|
||||
public void writeExportCsv(String customerId, LastlegOrderRequests.Query query, OutputStream output)
|
||||
throws IOException {
|
||||
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
output.write("\uFEFFID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜节点,是否异常,是否暂扣,托数,客户预报箱数,实收箱数,差异箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
final int batchSize = 1000;
|
||||
long page = 1;
|
||||
while (true) {
|
||||
@@ -585,7 +585,8 @@ public class LastlegOrderService {
|
||||
csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()),
|
||||
csv(order.getContainerNo()), csv(StringUtils.hasText(order.getSourceContainerBookingId())
|
||||
? containerNodes.get(order.getSourceContainerBookingId()) : null), csv(order.getAbnormal()), csv(order.getDetained()),
|
||||
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
|
||||
csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getReceivedPackageCount()),
|
||||
csv(packageDifference(order)), csv(order.getVolumeCbm()), csv(order.getProductQuantity()),
|
||||
csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()),
|
||||
csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(relabelBusinessStatus(order.getRelabelStatus())),
|
||||
csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()),
|
||||
@@ -677,7 +678,8 @@ public class LastlegOrderService {
|
||||
o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), containerNode,
|
||||
o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(),
|
||||
Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER",
|
||||
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(),
|
||||
o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(),
|
||||
o.getReceivedPackageCount(), packageDifference(o), o.getVolumeCbm(),
|
||||
o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(),
|
||||
o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), dispatchStatus, o.getRelabelOrderNo(), relabelBusinessStatus(o.getRelabelStatus()),
|
||||
o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null),
|
||||
@@ -686,6 +688,11 @@ public class LastlegOrderService {
|
||||
o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt());
|
||||
}
|
||||
|
||||
private static Integer packageDifference(LastlegOrder order) {
|
||||
if (order.getReceivedPackageCount() == null) return null;
|
||||
return order.getReceivedPackageCount() - java.util.Objects.requireNonNullElse(order.getPackageCount(), 0);
|
||||
}
|
||||
|
||||
static String displayOrderNo(LastlegOrder order) {
|
||||
return "WAREHOUSE_TRANSFER".equals(order.getOrderType()) && StringUtils.hasText(order.getSourceOrderNo())
|
||||
? order.getSourceOrderNo() : order.getOrderNo();
|
||||
|
||||
+55
@@ -12,6 +12,14 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/warehouse-transfers")
|
||||
@@ -24,6 +32,30 @@ public class MerchantWarehouseTransferController {
|
||||
return ApiResponse.success(service.create(customer(user), operator(user), request));
|
||||
}
|
||||
|
||||
@PutMapping("/{transferNo}")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> updateDraft(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
@Valid @RequestBody WarehouseTransferRequests.Create request) {
|
||||
return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 兼容入口:部分本地网关只放行 POST,草稿保存不能因此阻断提交。
|
||||
* 与 PUT 更新草稿使用完全相同的服务逻辑和租户校验。
|
||||
*/
|
||||
@PostMapping("/{transferNo}/save-draft")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> saveDraft(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
@Valid @RequestBody WarehouseTransferRequests.Create request) {
|
||||
return ApiResponse.success(service.updateDraft(customer(user), transferNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/submit")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> submit(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String transferNo) {
|
||||
return ApiResponse.success(service.submit(customer(user), transferNo, operator(user)));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid WarehouseTransferRequests.Query query) {
|
||||
@@ -36,6 +68,29 @@ public class MerchantWarehouseTransferController {
|
||||
return ApiResponse.success(service.detail(customer(user), transferNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/carton-label")
|
||||
public ApiResponse<WarehouseTransferResponses.Label> cartonLabel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String transferNo) {
|
||||
return ApiResponse.success(service.cartonLabel(customer(user), transferNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/measurement-photos/files")
|
||||
public ApiResponse<List<WarehouseTransferResponses.MeasurementPhoto>> measurementPhotos(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
|
||||
return ApiResponse.success(service.measurementPhotos(customer(user), transferNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download")
|
||||
public ResponseEntity<byte[]> downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String transferNo,
|
||||
@PathVariable long fileId) {
|
||||
var file = service.downloadMeasurementPhoto(customer(user), transferNo, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/confirm-measurement")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
|
||||
+90
@@ -16,7 +16,16 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@@ -32,6 +41,42 @@ public class PdaWarehouseTransferController {
|
||||
return ApiResponse.success(service.pdaCandidates(identity.overseasWarehouseId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.pdaDetail(identity.overseasWarehouseId(), transferNo));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{transferNo}/measurement-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ApiResponse<WarehouseTransferResponses.MeasurementPhoto> uploadMeasurementPhoto(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
long operatorUserId = user.userId() == null ? 0L : user.userId();
|
||||
return ApiResponse.success(service.uploadMeasurementPhoto(identity.operatorKey(), operatorUserId,
|
||||
identity.overseasWarehouseId(), transferNo, file));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/measurement-photos/files")
|
||||
public ApiResponse<List<WarehouseTransferResponses.MeasurementPhoto>> measurementPhotos(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.pdaMeasurementPhotos(identity.overseasWarehouseId(), transferNo));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/measurement-photos/files/{fileId}/download")
|
||||
public ResponseEntity<byte[]> downloadMeasurementPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String transferNo,
|
||||
@PathVariable long fileId) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
var file = service.downloadPdaMeasurementPhoto(identity.overseasWarehouseId(), transferNo, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/palletize")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> palletize(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
@@ -40,4 +85,49 @@ public class PdaWarehouseTransferController {
|
||||
return ApiResponse.success(service.palletize(identity.operatorKey(), identity.overseasWarehouseId(),
|
||||
transferNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/pallets/drafts")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> savePalletDraft(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
|
||||
@Valid @RequestBody WarehouseTransferRequests.PalletDraft request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.savePalletDraft(identity.overseasWarehouseId(), transferNo, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/pallets/{palletId}/generate")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> generatePallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId,
|
||||
@Valid @RequestBody WarehouseTransferRequests.PalletAction request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.generatePallet(identity.overseasWarehouseId(), transferNo, palletId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/pallets/{palletId}/bind")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> bindPallet(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId,
|
||||
@Valid @RequestBody WarehouseTransferRequests.PalletBind request) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.bindPallet(identity.overseasWarehouseId(), transferNo, palletId, request));
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.DeleteMapping("/{transferNo}/pallets/{palletId}")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> deletePalletDraft(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo, @PathVariable long palletId) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
return ApiResponse.success(service.deletePalletDraft(identity.overseasWarehouseId(), transferNo, palletId));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}/pallets/{warehousePalletId}/label")
|
||||
public ResponseEntity<byte[]> palletLabel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String transferNo,
|
||||
@PathVariable long warehousePalletId,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
|
||||
var file = service.printPdaPalletLabel(identity.overseasWarehouseId(), transferNo,
|
||||
warehousePalletId, requestId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(MediaType.parseMediaType(file.contentType())).body(file.content());
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -24,14 +24,14 @@ public class WarehouseTransferController {
|
||||
@PostMapping
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) {
|
||||
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||
return ApiResponse.success(service.createAndSubmit(scope(user, customerId), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @Valid WarehouseTransferRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query, false));
|
||||
}
|
||||
|
||||
@GetMapping("/{transferNo}")
|
||||
@@ -56,6 +56,17 @@ public class WarehouseTransferController {
|
||||
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/bind-destination")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
|
||||
public ApiResponse<java.util.List<WarehouseTransferResponses.Detail>> bindDestination(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody WarehouseTransferRequests.BindDestination request) {
|
||||
if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
|
||||
}
|
||||
return ApiResponse.success(service.bindDestination(operator(user), request));
|
||||
}
|
||||
|
||||
@PostMapping("/{transferNo}/allocations")
|
||||
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
|
||||
|
||||
+53
-11
@@ -21,7 +21,17 @@ public final class WarehouseTransferRequests {
|
||||
@Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds,
|
||||
@NotBlank @Size(max = 128) String operatingWarehouseId,
|
||||
@Size(max = 128) String fbaWarehouseCode,
|
||||
@Size(max = 16) String destinationType,
|
||||
@Size(max = 128) String destinationWarehouseId,
|
||||
@Size(max = 128) String destinationWarehouseCode,
|
||||
@Size(max = 255) String destinationWarehouseName,
|
||||
@Size(max = 1000) String privateDestinationAddress,
|
||||
@Size(max = 32) String privateDestinationPostalCode,
|
||||
@Size(max = 128) String privateDestinationContactName,
|
||||
@Size(max = 64) String privateDestinationContactPhone,
|
||||
@NotBlank @Size(max = 1000) String deliveryAddress,
|
||||
@Size(max = 128) String pickupContactName,
|
||||
@Size(max = 64) String pickupContactPhone,
|
||||
@Positive int packageCount,
|
||||
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
|
||||
@NotBlank @Size(max = 64) String deliveryMethod,
|
||||
@@ -46,12 +56,29 @@ public final class WarehouseTransferRequests {
|
||||
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
|
||||
String trackingNo, String fbaBoxNos, String remark, boolean needRelabel,
|
||||
String idempotencyKey, List<Item> items) {
|
||||
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode, deliveryAddress,
|
||||
packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
|
||||
this(inboundCode, sourceLastlegOrderIds, operatingWarehouseId, fbaWarehouseCode,
|
||||
null, null, null, null, null, null, null, null,
|
||||
deliveryAddress, null, null, packageCount, volumeCbm, deliveryMethod, estimatedArrivalAt, null, fbaShipmentNo, trackingNo,
|
||||
fbaBoxNos, remark, needRelabel, null, null, null, null, null, idempotencyKey, items, null);
|
||||
}
|
||||
}
|
||||
|
||||
public record DestinationSelection(
|
||||
@NotBlank @Size(max = 128) String customerId,
|
||||
@NotBlank @Size(max = 64) String transferNo) {}
|
||||
|
||||
public record BindDestination(
|
||||
@NotEmpty @Size(max = 100) List<@Valid DestinationSelection> transfers,
|
||||
@NotBlank @jakarta.validation.constraints.Pattern(regexp = "FBA|PRIVATE") String destinationType,
|
||||
@Size(max = 128) String destinationWarehouseId,
|
||||
@Size(max = 128) String destinationWarehouseCode,
|
||||
@Size(max = 255) String destinationWarehouseName,
|
||||
@Size(max = 1000) String privateDestinationAddress,
|
||||
@Size(max = 32) String privateDestinationPostalCode,
|
||||
@Size(max = 128) String privateDestinationContactName,
|
||||
@Size(max = 64) String privateDestinationContactPhone,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) {}
|
||||
|
||||
/** 审核通过备注选填;驳回必须提供非空原因,由服务在任何状态写入前校验。 */
|
||||
public record Review(@NotBlank @Size(max = 32) String decision,
|
||||
@Size(max = 1000) String remark) {}
|
||||
@@ -72,19 +99,34 @@ public final class WarehouseTransferRequests {
|
||||
|
||||
public record Palletize(
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotEmpty @Size(max = 100) List<@Valid Pallet> pallets
|
||||
@NotEmpty @Size(max = 20) List<@NotNull Long> photoFileIds
|
||||
) {}
|
||||
|
||||
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
|
||||
) {}
|
||||
|
||||
public record PalletAction(@NotBlank @Size(max = 255) String idempotencyKey) {}
|
||||
|
||||
public record PalletBind(@NotBlank @Size(max = 128) String palletCode,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) {}
|
||||
|
||||
public record Pallet(
|
||||
@Size(max = 128) String palletCode,
|
||||
@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,
|
||||
@NotEmpty @Size(max = 500) List<@Valid PalletItem> items
|
||||
@NotNull @DecimalMin("0.01") BigDecimal grossWeightKg
|
||||
) {}
|
||||
|
||||
public record PalletItem(@NotNull Long transferItemId, @Positive int packageCount) {}
|
||||
|
||||
public record CustomerConfirm(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
@@ -106,8 +148,8 @@ public final class WarehouseTransferRequests {
|
||||
) {}
|
||||
|
||||
public record Item(
|
||||
@NotBlank @Size(max = 128) String skuCode,
|
||||
@NotBlank @Size(max = 255) String productNameZh,
|
||||
@Size(max = 128) String skuCode,
|
||||
@Size(max = 255) String productNameZh,
|
||||
@Positive int packageCount,
|
||||
@Size(max = 255) String shippingMark
|
||||
) {}
|
||||
@@ -115,7 +157,7 @@ public final class WarehouseTransferRequests {
|
||||
public record RelabelItem(
|
||||
@NotBlank @Size(max = 32) String operationType,
|
||||
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> customerCartonMarks,
|
||||
@NotBlank @Size(max = 128) String originalSku,
|
||||
@Size(max = 128) String originalSku,
|
||||
@Size(max = 128) String newSku,
|
||||
@Positive int quantity,
|
||||
@Size(max = 255) String fbaCartonMark,
|
||||
|
||||
+17
-4
@@ -9,12 +9,16 @@ public final class 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 Label(String transferNo, List<Carton> cartons, String printableHtml) {}
|
||||
public record DestinationResult(String customerId, String transferNo) {}
|
||||
public record PalletItem(Long transferItemId, String skuCode, int packageCount) {}
|
||||
public record MeasurementPhoto(Long fileId, String fileName, String contentType, long sizeBytes,
|
||||
LocalDateTime uploadedAt) {}
|
||||
public record Pallet(Long id, int sequenceNo, Long warehousePalletId, String palletNo,
|
||||
Long warehouseLocationId, String warehouseLocationCode, int cartonCount,
|
||||
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
|
||||
BigDecimal grossWeightKg, BigDecimal volumeCbm, String status,
|
||||
List<PalletItem> items, LocalDateTime completedAt) {}
|
||||
String fbaWarehouseCode, String labelDownloadPath,
|
||||
List<PalletItem> items, LocalDateTime boundAt, LocalDateTime completedAt) {}
|
||||
public record RelabelBinding(String operationType, String customerCartonMark, String originalSku,
|
||||
String newSku, int quantity, String fbaCartonMark,
|
||||
String destinationWarehouseCode, String remark) {}
|
||||
@@ -24,19 +28,28 @@ public final class WarehouseTransferResponses {
|
||||
Long destinationInventoryPositionId) {}
|
||||
public record Summary(Long id, String transferNo, String inboundCode, Long lastlegOrderId,
|
||||
String lastlegOrderNo, String operatingWarehouseId, String fbaWarehouseCode,
|
||||
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
|
||||
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
|
||||
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
|
||||
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
|
||||
String status,
|
||||
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
|
||||
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
|
||||
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
|
||||
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
|
||||
String destinationType, String destinationWarehouseId, String destinationWarehouseCode,
|
||||
String destinationWarehouseName, String privateDestinationAddress,
|
||||
String privateDestinationPostalCode,
|
||||
String privateDestinationContactName, String privateDestinationContactPhone,
|
||||
String pickupContactName, String pickupContactPhone,
|
||||
int packageCount, BigDecimal volumeCbm, String deliveryMethod, LocalDateTime estimatedArrivalAt,
|
||||
LocalDateTime estimatedShipAt,
|
||||
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
|
||||
LocalDateTime labelDeadline, String relabelContactName, String relabelContactPhone,
|
||||
String relabelDeliveryMethod,
|
||||
String reviewDecision, String reviewRemark, String reviewedBy, LocalDateTime reviewedAt,
|
||||
List<SourceOrder> sourceOrders, List<Item> items, List<Carton> cartons,
|
||||
List<Pallet> pallets, List<RelabelBinding> relabelBindings,
|
||||
List<Allocation> allocations, LocalDateTime customerConfirmedAt,
|
||||
List<Allocation> allocations, List<MeasurementPhoto> measurementPhotos,
|
||||
LocalDateTime customerConfirmedAt,
|
||||
LocalDateTime actualShipAt, LocalDateTime createdAt) {}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.transfer.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("ll_warehouse_transfer_measurement_photo")
|
||||
public class WarehouseTransferMeasurementPhoto {
|
||||
@TableId(type = IdType.INPUT) private Long id;
|
||||
private Long transferOrderId;
|
||||
private String customerId;
|
||||
private Long fileId;
|
||||
private String fileName;
|
||||
private String contentType;
|
||||
private Long sizeBytes;
|
||||
private String uploadedBy;
|
||||
private LocalDateTime uploadedAt;
|
||||
private LocalDateTime submittedAt;
|
||||
}
|
||||
+12
@@ -18,7 +18,17 @@ public class WarehouseTransferOrder {
|
||||
private String lastlegOrderNo;
|
||||
private String operatingWarehouseId;
|
||||
private String fbaWarehouseCode;
|
||||
private String destinationType;
|
||||
private String destinationWarehouseId;
|
||||
private String destinationWarehouseCode;
|
||||
private String destinationWarehouseName;
|
||||
private String privateDestinationAddress;
|
||||
private String privateDestinationPostalCode;
|
||||
private String privateDestinationContactName;
|
||||
private String privateDestinationContactPhone;
|
||||
private String deliveryAddress;
|
||||
private String pickupContactName;
|
||||
private String pickupContactPhone;
|
||||
private Integer packageCount;
|
||||
private String deliveryMethod;
|
||||
private LocalDateTime estimatedArrivalAt;
|
||||
@@ -50,6 +60,8 @@ public class WarehouseTransferOrder {
|
||||
private String palletizeRequestHash;
|
||||
private String confirmationIdempotencyKey;
|
||||
private String confirmationRequestHash;
|
||||
private String destinationBindingIdempotencyKey;
|
||||
private String destinationBindingRequestHash;
|
||||
private LocalDateTime customerConfirmedAt;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
|
||||
+5
@@ -27,6 +27,11 @@ public class WarehouseTransferPallet {
|
||||
private BigDecimal grossWeightKg;
|
||||
private BigDecimal volumeCbm;
|
||||
private String status;
|
||||
private String draftIdempotencyKey;
|
||||
private String draftRequestHash;
|
||||
private String generateIdempotencyKey;
|
||||
private String bindIdempotencyKey;
|
||||
private LocalDateTime boundAt;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.baiyue.lastleg.transfer.gateway;
|
||||
|
||||
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
||||
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class HttpTransferMeasurementPhotoFileGateway implements TransferMeasurementPhotoFileGateway {
|
||||
private static final String TYPE = "OVERSEAS_TRANSFER_MEASUREMENT_PHOTO";
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Resource private LastlegRestClientFactory restClientFactory;
|
||||
@Resource private LastlegRestClientProperties restClientProperties;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9206}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
@PostConstruct void initializeClient() {
|
||||
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getRelabelPhotoFile());
|
||||
}
|
||||
|
||||
@Override public UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file) {
|
||||
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(file.getContentType()));
|
||||
} catch (IOException exception) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot read transfer measurement photo", exception);
|
||||
}
|
||||
MetadataEnvelope response = client.post().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}",
|
||||
tenant(customerId), TYPE, transferId)
|
||||
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId))
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo upload failed"); })
|
||||
.body(MetadataEnvelope.class);
|
||||
Metadata data = success(response, "Transfer measurement photo upload failed");
|
||||
return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
|
||||
}
|
||||
|
||||
@Override public void validate(String customerId, long transferId, long fileId) {
|
||||
long tenant = tenant(customerId);
|
||||
MetadataEnvelope response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}",
|
||||
tenant, TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo ownership is invalid"); })
|
||||
.body(MetadataEnvelope.class);
|
||||
Metadata data = success(response, "Transfer measurement photo ownership is invalid");
|
||||
if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenant
|
||||
|| !TYPE.equals(data.businessType()) || !String.valueOf(transferId).equals(data.businessId())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Transfer measurement photo ownership is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@Override public DownloadedFile download(String customerId, long transferId, long fileId) {
|
||||
validate(customerId, transferId, fileId);
|
||||
ResponseEntity<byte[]> response = client.get().uri("/internal/file/v1/business-files/{tenant}/{type}/{business}/{file}/content",
|
||||
tenant(customerId), TYPE, transferId, fileId).header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode(), "Transfer measurement photo download failed"); })
|
||||
.toEntity(byte[].class);
|
||||
String name = response.getHeaders().getContentDisposition().getFilename();
|
||||
String type = response.getHeaders().getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE
|
||||
: response.getHeaders().getContentType().toString();
|
||||
return new DownloadedFile(response.getBody() == null ? new byte[0] : response.getBody(),
|
||||
name == null ? "transfer-measurement-photo" : name, type);
|
||||
}
|
||||
|
||||
private static long tenant(String value) {
|
||||
try { return Long.parseLong(value); }
|
||||
catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); }
|
||||
}
|
||||
private static Metadata success(MetadataEnvelope response, String fallback) {
|
||||
if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback);
|
||||
if (!"200".equals(response.code())) {
|
||||
HttpStatus status = switch (String.valueOf(response.code())) {
|
||||
case "A0400", "A0422" -> HttpStatus.BAD_REQUEST;
|
||||
case "A0403" -> HttpStatus.FORBIDDEN;
|
||||
case "A0404" -> HttpStatus.NOT_FOUND;
|
||||
default -> HttpStatus.SERVICE_UNAVAILABLE;
|
||||
};
|
||||
throw new ResponseStatusException(status,
|
||||
response.message() == null || response.message().isBlank() ? fallback : response.message());
|
||||
}
|
||||
if (response.data() == null || response.data().fileId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"File service returned no transfer measurement photo metadata");
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
private static ResponseStatusException failure(HttpStatusCode status, String message) {
|
||||
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
private record MetadataEnvelope(String code, String message, Metadata data) {}
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes) {}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.lastleg.transfer.gateway;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface TransferMeasurementPhotoFileGateway {
|
||||
UploadedFile upload(String customerId, long transferId, long operatorId, MultipartFile file);
|
||||
void validate(String customerId, long transferId, long fileId);
|
||||
DownloadedFile download(String customerId, long transferId, long fileId);
|
||||
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||
record DownloadedFile(byte[] content, String fileName, String contentType) {}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.lastleg.transfer.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface WarehouseTransferMeasurementPhotoMapper extends BaseMapper<WarehouseTransferMeasurementPhoto> {
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY uploaded_at,id")
|
||||
List<WarehouseTransferMeasurementPhoto> listOrder(@Param("customerId") String customerId,
|
||||
@Param("transferOrderId") Long transferOrderId);
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND submitted_at IS NOT NULL ORDER BY submitted_at,id")
|
||||
List<WarehouseTransferMeasurementPhoto> listSubmitted(@Param("customerId") String customerId,
|
||||
@Param("transferOrderId") Long transferOrderId);
|
||||
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} LIMIT 1")
|
||||
WarehouseTransferMeasurementPhoto findFile(@Param("customerId") String customerId,
|
||||
@Param("transferOrderId") Long transferOrderId,
|
||||
@Param("fileId") Long fileId);
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_measurement_photo WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id=#{fileId} AND submitted_at IS NOT NULL LIMIT 1")
|
||||
WarehouseTransferMeasurementPhoto findSubmittedFile(@Param("customerId") String customerId,
|
||||
@Param("transferOrderId") Long transferOrderId,
|
||||
@Param("fileId") Long fileId);
|
||||
@Update("<script>UPDATE ll_warehouse_transfer_measurement_photo SET submitted_at=#{submittedAt} WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND file_id IN <foreach collection='fileIds' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
int markSubmitted(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId,
|
||||
@Param("fileIds") java.util.Collection<Long> fileIds,
|
||||
@Param("submittedAt") java.time.LocalDateTime submittedAt);
|
||||
}
|
||||
+2
@@ -14,6 +14,8 @@ import org.apache.ibatis.annotations.Update;
|
||||
WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo);
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE")
|
||||
WarehouseTransferOrder lockForPda(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE transfer_no=#{transferNo} AND operating_warehouse_id=#{warehouseId} LIMIT 1")
|
||||
WarehouseTransferOrder findNoByWarehouse(@Param("transferNo") String transferNo, @Param("warehouseId") String warehouseId);
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE")
|
||||
WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id")
|
||||
|
||||
+5
@@ -13,4 +13,9 @@ public interface WarehouseTransferPalletMapper extends BaseMapper<WarehouseTrans
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} ORDER BY sequence_no")
|
||||
List<WarehouseTransferPallet> listOrder(@Param("customerId") String customerId,
|
||||
@Param("transferOrderId") Long transferOrderId);
|
||||
|
||||
@Select("SELECT * FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferOrderId} AND id=#{id} FOR UPDATE")
|
||||
WarehouseTransferPallet lockOne(@Param("customerId") String customerId,
|
||||
@Param("transferOrderId") Long transferOrderId,
|
||||
@Param("id") Long id);
|
||||
}
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.transfer.model;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** 仓储转运单状态。状态编码持久化到数据库,中文名称用于接口和前端展示。 */
|
||||
public enum WarehouseTransferStatus {
|
||||
DRAFT("草稿"),
|
||||
PENDING_REVIEW("待审核"),
|
||||
APPROVED("审核通过"),
|
||||
REJECTED("审核驳回"),
|
||||
WAIT_PDA_RECEIVING("待PDA收货"),
|
||||
INBOUND_MEASURING("入库测量中"),
|
||||
WAIT_CUSTOMER_CONFIRMATION("待客户确认实测"),
|
||||
WAIT_RELABEL("待换标"),
|
||||
WAIT_FBA_DESTINATION("待确认目的仓"),
|
||||
READY_FOR_DISPATCH("待排车"),
|
||||
WAIT_OUTBOUND("待出库"),
|
||||
PENDING_ASSIGNMENT("待分配"),
|
||||
ALLOCATED("已分配库存"),
|
||||
OUTBOUNDED("已出库"),
|
||||
PARTIALLY_RECEIVED("部分收货"),
|
||||
RECEIVED("已收货"),
|
||||
INBOUND_COMPLETED("入库完成"),
|
||||
COMPLETED("已完成");
|
||||
|
||||
private final String label;
|
||||
|
||||
WarehouseTransferStatus(String label) { this.label = label; }
|
||||
|
||||
public String code() { return name(); }
|
||||
public String label() { return label; }
|
||||
|
||||
public static WarehouseTransferStatus fromCode(String code) {
|
||||
return Arrays.stream(values()).filter(value -> value.name().equals(code)).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Unsupported warehouse transfer status: " + code));
|
||||
}
|
||||
}
|
||||
+876
-161
File diff suppressed because it is too large
Load Diff
@@ -76,9 +76,9 @@ baiyue:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL:}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
file:
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
|
||||
domestic-file:
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9205}
|
||||
url: ${FILE_SERVICE_URL:http://127.0.0.1:9206}
|
||||
event-bridge:
|
||||
url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}}
|
||||
firstleg:
|
||||
|
||||
@@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS ll_order (
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本',
|
||||
pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
|
||||
received_package_count INT NULL COMMENT 'PDA实际入库箱数',
|
||||
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数',
|
||||
shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数',
|
||||
volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)',
|
||||
@@ -267,14 +268,19 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号',
|
||||
outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID',
|
||||
outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号',
|
||||
dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID',
|
||||
dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号',
|
||||
dispatch_order_id BIGINT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空',
|
||||
dispatch_no VARCHAR(64) NULL COMMENT '关联派车单号,仓储转运直接出库时为空',
|
||||
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID',
|
||||
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '本派送单目的FBA仓ID快照',
|
||||
destination_warehouse_code VARCHAR(128) NOT NULL COMMENT '本派送单目的FBA仓编码快照',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '本派送单目的FBA仓名称快照',
|
||||
destination_type VARCHAR(16) NULL COMMENT '目的地类型:FBA或PRIVATE',
|
||||
delivery_address VARCHAR(1000) NULL COMMENT '私人仓派送地址',
|
||||
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编',
|
||||
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话',
|
||||
delivery_group_key VARCHAR(255) NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
|
||||
waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
|
||||
@@ -318,6 +324,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓ID快照'' AFTER customer_name, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''本派送单目的FBA仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN delivery_group_key VARCHAR(255) NULL COMMENT ''同一出库单内按目的FBA仓拆单的稳定分组键'' AFTER destination_warehouse_name'));
|
||||
PREPARE delivery_destination_stmt FROM @ddl; EXECUTE delivery_destination_stmt; DEALLOCATE PREPARE delivery_destination_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的地类型:FBA或PRIVATE'' AFTER destination_warehouse_name, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓派送地址'' AFTER destination_type, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER delivery_address, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER contact_name'));
|
||||
PREPARE delivery_private_destination_stmt FROM @ddl; EXECUTE delivery_private_destination_stmt; DEALLOCATE PREPARE delivery_private_destination_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address'));
|
||||
PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''关联派车单ID,仓储转运直接出库时为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''关联派车单号,仓储转运直接出库时为空'', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''本派送单目的FBA仓编码快照,私人仓为空'''));
|
||||
PREPARE delivery_transfer_nullable_stmt FROM @ddl; EXECUTE delivery_transfer_nullable_stmt; DEALLOCATE PREPARE delivery_transfer_nullable_stmt;
|
||||
SET @old_delivery_outbound_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound');
|
||||
SET @new_delivery_destination_index = (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND index_name='uk_ll_delivery_outbound_destination');
|
||||
SET @ddl = IF(@new_delivery_destination_index > 0, 'SELECT 1', IF(@old_delivery_outbound_index > 0, 'ALTER TABLE ll_delivery_order DROP INDEX uk_ll_delivery_outbound, ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)', 'ALTER TABLE ll_delivery_order ADD UNIQUE KEY uk_ll_delivery_outbound_destination (customer_id,outbound_order_id,delivery_group_key)'));
|
||||
@@ -599,7 +611,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan (
|
||||
assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是',
|
||||
merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是',
|
||||
urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是',
|
||||
creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工',
|
||||
creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运',
|
||||
plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态',
|
||||
line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量',
|
||||
@@ -648,6 +660,10 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line (
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照',
|
||||
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照',
|
||||
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照',
|
||||
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)',
|
||||
planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量',
|
||||
PRIMARY KEY (id),
|
||||
@@ -682,6 +698,11 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE',
|
||||
delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照',
|
||||
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照',
|
||||
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照',
|
||||
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
|
||||
allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
|
||||
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
|
||||
@@ -721,8 +742,15 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag'));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运'' AFTER urgent_flag'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
ALTER TABLE ll_inbound_plan MODIFY COLUMN creation_mode VARCHAR(32) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运';
|
||||
UPDATE ll_inbound_plan_pallet_allocation a
|
||||
JOIN ll_inbound_plan p ON p.id=a.plan_id
|
||||
SET a.picked_package_count=COALESCE(a.counted_package_count,a.planned_package_count,0)
|
||||
WHERE p.creation_mode='WAREHOUSE_TRANSFER' AND p.status='COMPLETED'
|
||||
AND COALESCE(a.picked_package_count,0)=0
|
||||
AND COALESCE(a.counted_package_count,a.planned_package_count,0)>0;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode'));
|
||||
PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt;
|
||||
ALTER TABLE ll_inbound_plan
|
||||
@@ -1329,6 +1357,11 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码',
|
||||
destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE',
|
||||
delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照',
|
||||
postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照',
|
||||
contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
|
||||
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)',
|
||||
source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
|
||||
@@ -1346,6 +1379,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
@@ -1521,7 +1555,11 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
|
||||
transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
|
||||
lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号',
|
||||
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码',
|
||||
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
|
||||
destination_type VARCHAR(16) NULL COMMENT '目的类型:FBA或PRIVATE', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照',
|
||||
destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照',
|
||||
private_destination_address VARCHAR(1000) NULL COMMENT '私人仓完整地址', private_destination_postal_code VARCHAR(32) NULL COMMENT '私人仓邮编', private_destination_contact_name VARCHAR(128) NULL COMMENT '私人仓联系人',
|
||||
private_destination_contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话',
|
||||
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', pickup_contact_name VARCHAR(128) NULL COMMENT '提货联系人', pickup_contact_phone VARCHAR(64) NULL COMMENT '提货联系电话', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
|
||||
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
|
||||
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
|
||||
@@ -1559,6 +1597,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at'));
|
||||
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address'));
|
||||
PREPARE transfer_pickup_contact_name_stmt FROM @ddl; EXECUTE transfer_pickup_contact_name_stmt; DEALLOCATE PREPARE transfer_pickup_contact_name_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='pickup_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_phone VARCHAR(64) NULL COMMENT ''提货联系电话'' AFTER pickup_contact_name'));
|
||||
PREPARE transfer_pickup_contact_phone_stmt FROM @ddl; EXECUTE transfer_pickup_contact_phone_stmt; DEALLOCATE PREPARE transfer_pickup_contact_phone_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='label_deadline'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN label_deadline DATETIME(3) NULL COMMENT ''换标截止时间'', ADD COLUMN relabel_contact_name VARCHAR(128) NULL COMMENT ''换标联系人'', ADD COLUMN relabel_contact_phone VARCHAR(64) NULL COMMENT ''换标联系电话'', ADD COLUMN relabel_charge_amount DECIMAL(20,2) NULL COMMENT ''换标费用CNY'', ADD COLUMN relabel_delivery_method VARCHAR(32) NULL COMMENT ''换标后派送方式'''));
|
||||
PREPARE transfer_relabel_config_stmt FROM @ddl; EXECUTE transfer_relabel_config_stmt; DEALLOCATE PREPARE transfer_relabel_config_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''关联贴标完成时间'' AFTER relabel_no'));
|
||||
@@ -1567,6 +1609,20 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE transfer_fulfillment_stmt FROM @ddl; EXECUTE transfer_fulfillment_stmt; DEALLOCATE PREPARE transfer_fulfillment_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='review_decision'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN review_decision VARCHAR(32) NULL COMMENT ''审核决定'' AFTER status, ADD COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注或驳回原因'' AFTER review_decision, ADD COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID'' AFTER review_remark, ADD COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间'' AFTER reviewed_by'));
|
||||
PREPARE transfer_review_fact_stmt FROM @ddl; EXECUTE transfer_review_fact_stmt; DEALLOCATE PREPARE transfer_review_fact_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN destination_type VARCHAR(16) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照'' AFTER destination_type, ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照'' AFTER destination_warehouse_id, ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照'' AFTER destination_warehouse_code, ADD COLUMN private_destination_address VARCHAR(1000) NULL COMMENT ''私人仓完整地址'' AFTER destination_warehouse_name, ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address, ADD COLUMN private_destination_contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人'' AFTER private_destination_postal_code, ADD COLUMN private_destination_contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话'' AFTER private_destination_contact_name, ADD COLUMN destination_binding_idempotency_key VARCHAR(255) NULL COMMENT ''目的地绑定幂等键'', ADD COLUMN destination_binding_request_hash CHAR(64) NULL COMMENT ''目的地绑定请求哈希'''));
|
||||
PREPARE transfer_destination_stmt FROM @ddl; EXECUTE transfer_destination_stmt; DEALLOCATE PREPARE transfer_destination_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='delivery_address'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''));
|
||||
PREPARE transfer_line_destination_stmt FROM @ddl; EXECUTE transfer_line_destination_stmt; DEALLOCATE PREPARE transfer_line_destination_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'', ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'', ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'', ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'', ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'''));
|
||||
PREPARE transfer_pallet_destination_stmt FROM @ddl; EXECUTE transfer_pallet_destination_stmt; DEALLOCATE PREPARE transfer_pallet_destination_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='private_destination_postal_code'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN private_destination_postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER private_destination_address'));
|
||||
PREPARE transfer_postal_stmt FROM @ddl; EXECUTE transfer_postal_stmt; DEALLOCATE PREPARE transfer_postal_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
|
||||
PREPARE transfer_line_postal_stmt FROM @ddl; EXECUTE transfer_line_postal_stmt; DEALLOCATE PREPARE transfer_line_postal_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
|
||||
PREPARE transfer_pallet_postal_stmt FROM @ddl; EXECUTE transfer_pallet_postal_stmt; DEALLOCATE PREPARE transfer_pallet_postal_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address'));
|
||||
PREPARE dispatch_item_postal_stmt FROM @ddl; EXECUTE dispatch_item_postal_stmt; DEALLOCATE PREPARE dispatch_item_postal_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -1603,18 +1659,22 @@ PREPARE transfer_customer_carton_stmt FROM @ddl; EXECUTE transfer_customer_carto
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet (
|
||||
id BIGINT NOT NULL COMMENT '打托事实ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', sequence_no INT NOT NULL COMMENT '托序号',
|
||||
warehouse_pallet_id BIGINT NOT NULL COMMENT '海外仓真实托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '真实托盘码',
|
||||
warehouse_location_id BIGINT NOT NULL COMMENT '真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码',
|
||||
warehouse_resource_lock_id BIGINT NOT NULL COMMENT '仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '生成后的海外仓真实托盘ID', pallet_no VARCHAR(128) NULL COMMENT '生成后的真实托盘码',
|
||||
warehouse_location_id BIGINT NULL COMMENT '生成后的真实库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '真实库位码',
|
||||
warehouse_resource_lock_id BIGINT NULL COMMENT '生成后的仓储资源锁ID', carton_count INT NOT NULL COMMENT '本托实收箱数',
|
||||
length_cm DECIMAL(12,2) NOT NULL COMMENT '实测长cm', width_cm DECIMAL(12,2) NOT NULL COMMENT '实测宽cm',
|
||||
height_cm DECIMAL(12,2) NOT NULL COMMENT '实测高cm', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '实测毛重kg',
|
||||
volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '打托状态',
|
||||
completed_at DATETIME(3) NOT NULL COMMENT 'PDA打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
volume_cbm DECIMAL(18,6) NOT NULL COMMENT '实测体积CBM', status VARCHAR(32) NOT NULL COMMENT '逐托状态DRAFT/GENERATED/BOUND/COMPLETED',
|
||||
draft_idempotency_key VARCHAR(255) NULL COMMENT '草稿保存幂等键', draft_request_hash CHAR(64) NULL COMMENT '草稿请求摘要',
|
||||
generate_idempotency_key VARCHAR(255) NULL COMMENT '新托盘生成幂等键', bind_idempotency_key VARCHAR(255) NULL COMMENT '扫码绑定幂等键',
|
||||
bound_at DATETIME(3) NULL COMMENT '扫码确认绑定时间', completed_at DATETIME(3) NULL COMMENT '整单打托完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_transfer_pallet_seq(transfer_order_id,sequence_no),
|
||||
UNIQUE KEY uk_ll_transfer_pallet_physical(customer_id,warehouse_pallet_id),
|
||||
KEY idx_ll_transfer_pallet_order(customer_id,transfer_order_id,status)
|
||||
) ENGINE=InnoDB COMMENT='仓储转运PDA实测打托事实';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_pallet' AND column_name='draft_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''生成后的海外仓真实托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''生成后的真实托盘码'', MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''生成后的真实库位ID'', MODIFY COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''生成后的仓储资源锁ID'', MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''整单打托完成时间'', ADD COLUMN draft_idempotency_key VARCHAR(255) NULL COMMENT ''草稿保存幂等键'' AFTER status, ADD COLUMN draft_request_hash CHAR(64) NULL COMMENT ''草稿请求摘要'' AFTER draft_idempotency_key, ADD COLUMN generate_idempotency_key VARCHAR(255) NULL COMMENT ''新托盘生成幂等键'' AFTER draft_request_hash, ADD COLUMN bind_idempotency_key VARCHAR(255) NULL COMMENT ''扫码绑定幂等键'' AFTER generate_idempotency_key, ADD COLUMN bound_at DATETIME(3) NULL COMMENT ''扫码确认绑定时间'' AFTER bind_idempotency_key'));
|
||||
PREPARE transfer_pallet_lifecycle_stmt FROM @ddl; EXECUTE transfer_pallet_lifecycle_stmt; DEALLOCATE PREPARE transfer_pallet_lifecycle_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
|
||||
@@ -1625,6 +1685,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_pallet_item (
|
||||
KEY idx_ll_transfer_pallet_item_order(customer_id,transfer_order_id)
|
||||
) ENGINE=InnoDB COMMENT='仓储转运每托SKU箱数回填';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_measurement_photo (
|
||||
id BIGINT NOT NULL COMMENT '测量照片引用ID', transfer_order_id BIGINT NOT NULL COMMENT '仓储转运单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', file_id BIGINT NOT NULL COMMENT '文件服务文件ID',
|
||||
file_name VARCHAR(255) NULL COMMENT '文件名快照', content_type VARCHAR(128) NULL COMMENT '文件媒体类型快照',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '文件字节数快照', uploaded_by VARCHAR(128) NULL COMMENT 'PDA上传操作人',
|
||||
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', submitted_at DATETIME(3) NULL COMMENT '随测量打托提交时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_transfer_measurement_photo_file(customer_id,transfer_order_id,file_id),
|
||||
KEY idx_ll_transfer_measurement_photo_order(customer_id,transfer_order_id,uploaded_at)
|
||||
) ENGINE=InnoDB COMMENT='仓储转运PDA测量现场照片引用';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_measurement_photo' AND column_name='submitted_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_measurement_photo ADD COLUMN submitted_at DATETIME(3) NULL COMMENT ''随测量打托提交时间'' AFTER uploaded_at'));
|
||||
PREPARE transfer_measurement_photo_submitted_stmt FROM @ddl; EXECUTE transfer_measurement_photo_submitted_stmt; DEALLOCATE PREPARE transfer_measurement_photo_submitted_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_relabel_binding (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', operation_type VARCHAR(32) NOT NULL COMMENT '换标类型',
|
||||
|
||||
@@ -1310,7 +1310,7 @@ paths:
|
||||
/api/merchant/v1/last-leg/relabel-applications/inventory-candidates:
|
||||
get:
|
||||
summary: 查询客户可换标库存候选
|
||||
description: 权限 merchant:warehouse:relabel;仅返回当前客户在指定海外中转仓仍有可用箱数的历史入仓单和库位摘要。候选按客户 Token 隔离,不接收 customerId;inboundCode 可选精确筛选。
|
||||
description: 权限 merchant:warehouse:relabel;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } }
|
||||
@@ -2531,6 +2531,19 @@ components:
|
||||
availablePackages: { type: integer, format: int64 }
|
||||
version: { type: integer, format: int64 }
|
||||
positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } }
|
||||
sourceType: { type: string, enum: [INBOUND, RETURN_CARTON] }
|
||||
returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 }
|
||||
cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 }
|
||||
skuItems:
|
||||
type: array
|
||||
description: 退货箱内 SKU 与件数
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
skuCode: { type: string }
|
||||
newSkuCode: { type: string, nullable: true }
|
||||
quantity: { type: integer, minimum: 1 }
|
||||
remark: { type: string, nullable: true }
|
||||
RelabelInventoryPosition:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2559,10 +2572,10 @@ components:
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
RelabelCreate:
|
||||
type: object
|
||||
required: [customerOrderNo, labelDeadline, contactName, contactPhone, idempotencyKey, items]
|
||||
required: [customerOrderNo, operationWarehouseId, labelDeadline, contactName, contactPhone, idempotencyKey, items]
|
||||
properties:
|
||||
customerOrderNo: { type: string, maxLength: 128 }
|
||||
overseasWarehouseId: { type: string, nullable: true, maxLength: 128, description: 统一目的海外仓;明细未填写目的仓时使用 }
|
||||
operationWarehouseId: { type: string, maxLength: 128, description: 当前库存所在海外操作仓 }
|
||||
labelDeadline: { type: string, format: date-time }
|
||||
contactName: { type: string, maxLength: 128 }
|
||||
contactPhone: { type: string, maxLength: 64 }
|
||||
@@ -2576,6 +2589,10 @@ components:
|
||||
type: object
|
||||
required: [productNameZh, totalPackageCount, notifiedPackageCount, shipmentNo, newSkuCode, cartonMark, fbaCartonMark]
|
||||
properties:
|
||||
sourceInventoryBalanceId: { type: string, nullable: true, description: 普通入仓库存来源;与 returnCartonId 二选一 }
|
||||
sourceInventoryPositionId: { type: string, nullable: true, description: 普通入仓库存仓位;与 returnCartonId 二选一 }
|
||||
sourceInboundOrderId: { type: string, nullable: true, description: 普通入仓单;与 returnCartonId 二选一 }
|
||||
returnCartonId: { type: string, nullable: true, description: 仓库合箱产生的退货箱库存;与普通入仓库存字段二选一 }
|
||||
skuCode: { type: string, nullable: true, maxLength: 128, description: 商品 SKU(选填) }
|
||||
productImageFileId: { type: string, nullable: true, maxLength: 128 }
|
||||
productImageFileName: { type: string, nullable: true, maxLength: 255 }
|
||||
|
||||
@@ -163,12 +163,32 @@ paths:
|
||||
summary: 扫码查询托盘及库存内容
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
|
||||
responses: { '200': { description: Token绑定仓托盘内容 }, '404': { description: 托盘不存在(统一响应实际 HTTP 200,body.code=A0404), content: { application/json: { schema: { $ref: '#/components/schemas/ApiResponse' } } } } }
|
||||
/api/pda/v1/last-leg/pallet-operations/relabel-sources:
|
||||
post:
|
||||
summary: 查询换标原入仓单在各托盘上的库存数量
|
||||
description: 范围取Token冻结仓和换标单客户;返回库存组成而非历史箱唛定位。movablePackages不包括其他单据冻结、已分配或已拣货箱数。cartonMarks为同来源库存池候选,不代表这些箱历史上位于该托盘。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { type: object, required: [relabelNo], properties: { relabelNo: { type: string, maxLength: 64 } } }
|
||||
responses:
|
||||
'200':
|
||||
description: 原入仓单托盘组成
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: { type: string }
|
||||
data: { type: array, items: { $ref: '#/components/schemas/RelabelSource' } }
|
||||
'409': { description: 状态、认领人或仓库不符 }
|
||||
/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label:
|
||||
post:
|
||||
summary: 为换标拆托幂等生成并返回可打印的新托盘标签
|
||||
description: 返回空托盘编码、初始版本和打印HTML;随后可将该托盘作为relabel-disassemble的target。库存移入后仍继承原库存余额对应的客户和入库单。
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/NewRelabelPallet' } } } }
|
||||
responses: { '200': { description: 新托盘及可打印标签 }, '409': { description: 换标单未确认或不属于Token绑定仓 } }
|
||||
responses: { '200': { description: 新托盘及可打印标签,已认领处理中无需客户确认即可创建 }, '409': { description: 状态、认领人或Token绑定仓不符 } }
|
||||
/api/pda/v1/last-leg/pallet-operations/assemble:
|
||||
post:
|
||||
summary: 将多个来源托盘的指定库存行组到目标托盘
|
||||
@@ -182,7 +202,7 @@ paths:
|
||||
/api/pda/v1/last-leg/pallet-operations/relabel-disassemble:
|
||||
post:
|
||||
summary: 按换标箱唛拆托并绑定目标托盘
|
||||
description: PDA先扫描原托盘,再扫描换标单指定箱唛;服务端按换标单库存快照解析仓位,在同一事务中扣减原托盘并绑定目标托盘。目标托盘须先由仓储服务创建后扫码确认。
|
||||
description: PDA选择原入仓单库存行并扫描实际来源托盘,输入拆出箱数,核对本批cartonMarks后扫描目标托盘。传actualSource时以其数量和版本为准,同入仓余额及批次内原子调整本单冻结记账位置,再移动冻结库存和换标明细;不新增入库、不改变库存归属、不占用其他单据冻结量。每次一个来源库存行,可分批处理多个托盘。已有出库单时不允许此数量重分配。未传actualSource的箱唛模式仍要求已有来源仓位一致。
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPalletDisassemble' } } } }
|
||||
responses: { '200': { description: 箱唛级拆托绑定结果 }, '409': { description: 箱唛、库存或托盘版本冲突 } }
|
||||
/api/pda/v1/last-leg/pallet-operations/swap:
|
||||
@@ -218,6 +238,27 @@ paths:
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } }
|
||||
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/skus/scan:
|
||||
post:
|
||||
summary: 仅扫描旧SKU完成当前箱的一条SKU换标明细
|
||||
description: itemId与skuIndex精确指定一条明细,服务端验证旧SKU并读取已批准的新SKU及件数;同旧SKU多箱由PDA选择目标,不批量隐式完成。逐条写SKU_SCAN历史并按轮次防重,全部SKU处理后完成SKU阶段,混合单仍须完成箱唛阶段。响应history包含旧新SKU、件数及索引,供提示与隐藏已处理明细。
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [itemId, skuIndex, oldSku, expectedVersion, idempotencyKey]
|
||||
properties:
|
||||
itemId: { type: string, pattern: '^[0-9]+$' }
|
||||
skuIndex: { type: integer, minimum: 0 }
|
||||
oldSku: { type: string, maxLength: 255 }
|
||||
expectedVersion: { type: integer, minimum: 0 }
|
||||
idempotencyKey: { type: string, maxLength: 255 }
|
||||
responses:
|
||||
'200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }
|
||||
'409': { description: 已处理、映射不符、认领人或版本冲突 }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:
|
||||
post:
|
||||
summary: 登记一件有原因的跳过事实
|
||||
@@ -260,6 +301,107 @@ paths:
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } }
|
||||
responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/candidates:
|
||||
get:
|
||||
summary: 查询Token绑定仓待测量的仓储转运单
|
||||
description: 仅返回当前PDA冻结海外仓内已审核且等待测量打托的转运单;不接收customerId或warehouseId。
|
||||
responses:
|
||||
'200':
|
||||
description: 待测量转运单列表
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferSummaryListResponse' } } }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}:
|
||||
get:
|
||||
summary: 查询当前仓仓储转运测量详情
|
||||
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
|
||||
responses:
|
||||
'200':
|
||||
description: 转运申报、已生成的新托盘及测量照片;items仅为服务端自动分摊结果,PDA不提交每托SKU。
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } }
|
||||
'404': { description: 当前Token绑定仓无此转运单 }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files:
|
||||
get:
|
||||
summary: 查询当前转运单已上传的测量现场照片
|
||||
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
|
||||
responses:
|
||||
'200': { description: 照片元数据列表 }
|
||||
post:
|
||||
summary: 上传仓储转运测量现场照片
|
||||
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties: { file: { type: string, format: binary } }
|
||||
responses:
|
||||
'200': { description: 已绑定当前转运单的照片元数据 }
|
||||
'404': { description: 当前Token绑定仓无此转运单 }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/measurement-photos/files/{fileId}/download:
|
||||
get:
|
||||
summary: 鉴权下载仓储转运测量现场照片
|
||||
parameters:
|
||||
- { name: transferNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
responses:
|
||||
'200': { description: 照片内容, content: { 'image/*': { schema: { type: string, format: binary } } } }
|
||||
'404': { description: 照片不属于当前转运单或绑定仓 }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/palletize:
|
||||
post:
|
||||
summary: 完成仓储转运整单测量打托
|
||||
description: 只提交当前转运单照片和整单幂等键;所有托盘必须已逐托生成并扫码绑定,箱数必须核平。此接口不接收测量字段、托盘码或每托SKU,也绝不再次生成托盘。
|
||||
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletize' } } } }
|
||||
responses:
|
||||
'200':
|
||||
description: WAIT_CUSTOMER_CONFIRMATION详情
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferDetailResponse' } } }
|
||||
'409': { description: 状态、照片归属、箱数核平或幂等冲突 }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/drafts:
|
||||
post:
|
||||
summary: 新建或更新一个转运测量托盘草稿
|
||||
description: palletId为空时创建DRAFT;传入既有DRAFT的palletId时更新。GENERATED及以后测量字段锁定,必须拒绝修改。
|
||||
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletDraft' } } } }
|
||||
responses: { '200': { description: 包含持久化草稿的最新整单详情 }, '409': { description: 草稿已生成、状态或幂等冲突 } }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}:
|
||||
delete:
|
||||
summary: 删除未生成的转运托盘草稿
|
||||
description: 只允许删除DRAFT;GENERATED、BOUND和COMPLETED不得删除。
|
||||
parameters:
|
||||
- { name: transferNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
responses: { '200': { description: 删除后的整单详情 }, '409': { description: 本托已经生成真实托盘 } }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/generate:
|
||||
post:
|
||||
summary: 为测量草稿生成一个全新实物托盘
|
||||
description: 只允许DRAFT调用一次;服务端始终向Warehouse Overseas传空palletCode,不能复用已有托盘。有FBA则写入目的仓,无FBA则标签显示“目的仓待定”。成功后实测字段锁定且可立即下载PDF标签。
|
||||
parameters:
|
||||
- { name: transferNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletAction' } } } }
|
||||
responses: { '200': { description: 包含GENERATED新托盘号和标签路径的最新详情 }, '409': { description: 已生成或资源生成冲突 } }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{palletId}/bind:
|
||||
post:
|
||||
summary: 扫描并确认本托新生成的托盘码
|
||||
description: 扫描码必须精确等于本草稿服务端生成码,同时验证Warehouse Overseas物理托盘ID和Token冻结仓一致;其他已有托盘不得冒充。
|
||||
parameters:
|
||||
- { name: transferNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: palletId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseTransferPalletBind' } } } }
|
||||
responses: { '200': { description: 包含BOUND事实和boundAt的最新详情 }, '409': { description: 扫描码、归属、仓库或状态不匹配 } }
|
||||
/api/pda/v1/last-leg/warehouse-transfers/{transferNo}/pallets/{warehousePalletId}/label:
|
||||
get:
|
||||
summary: 下载仓储转运实物托盘PDF标签
|
||||
description: 校验Token冻结仓、转运单和托盘归属后,代理Warehouse Overseas使用统一托盘标签打印服务生成真实PDF。已知FBA显示目的仓编码,未知FBA显示“目的仓待定”。
|
||||
parameters:
|
||||
- { name: transferNo, in: path, required: true, schema: { type: string } }
|
||||
- { name: warehousePalletId, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
- { name: X-Request-Id, in: header, required: false, schema: { type: string } }
|
||||
responses:
|
||||
'200': { description: 托盘标签PDF, content: { application/pdf: { schema: { type: string, format: binary } } } }
|
||||
'404': { description: 转运单或托盘不属于当前Token绑定仓 }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
@@ -274,6 +416,107 @@ components:
|
||||
requestId: { type: string, nullable: true }
|
||||
traceId: { type: string, nullable: true }
|
||||
timestamp: { type: string, format: date-time, nullable: true }
|
||||
WarehouseTransferSummaryListResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferSummary' } }
|
||||
WarehouseTransferDetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/WarehouseTransferDetail' } }
|
||||
WarehouseTransferSummary:
|
||||
type: object
|
||||
required: [id, transferNo, operatingWarehouseId, packageCount, deliveryMethod, needRelabel, status]
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
transferNo: { type: string }
|
||||
inboundCode: { type: string, nullable: true }
|
||||
operatingWarehouseId: { type: string }
|
||||
fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 }
|
||||
packageCount: { type: integer, minimum: 1 }
|
||||
deliveryMethod: { type: string }
|
||||
needRelabel: { type: boolean }
|
||||
relabelNo: { type: string, nullable: true }
|
||||
status: { type: string }
|
||||
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
|
||||
createdAt: { type: string, format: date-time, nullable: true }
|
||||
WarehouseTransferDetail:
|
||||
type: object
|
||||
required: [id, transferNo, operatingWarehouseId, packageCount, status, pallets, measurementPhotos]
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
transferNo: { type: string }
|
||||
operatingWarehouseId: { type: string }
|
||||
fbaWarehouseCode: { type: string, nullable: true, description: 为空表示目的仓待定 }
|
||||
pickupContactName: { type: string, nullable: true }
|
||||
pickupContactPhone: { type: string, nullable: true }
|
||||
packageCount: { type: integer, minimum: 1 }
|
||||
status: { type: string }
|
||||
pallets: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferPallet' } }
|
||||
measurementPhotos: { type: array, items: { $ref: '#/components/schemas/WarehouseTransferMeasurementPhoto' } }
|
||||
WarehouseTransferPalletize:
|
||||
type: object
|
||||
required: [idempotencyKey, photoFileIds]
|
||||
properties:
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
photoFileIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 20
|
||||
uniqueItems: true
|
||||
items: { type: integer, format: int64, minimum: 1 }
|
||||
WarehouseTransferPalletDraft:
|
||||
type: object
|
||||
required: [idempotencyKey, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg]
|
||||
properties:
|
||||
palletId: { type: integer, format: int64, nullable: true, description: 更新现有DRAFT时传入;创建时不传 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
cartonCount: { type: integer, minimum: 1 }
|
||||
lengthCm: { type: number, format: double, minimum: 0.01 }
|
||||
widthCm: { type: number, format: double, minimum: 0.01 }
|
||||
heightCm: { type: number, format: double, minimum: 0.01 }
|
||||
volumeCbm: { type: number, format: double, minimum: 0.000001 }
|
||||
grossWeightKg: { type: number, format: double, minimum: 0.01 }
|
||||
WarehouseTransferPalletAction:
|
||||
type: object
|
||||
required: [idempotencyKey]
|
||||
properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } }
|
||||
WarehouseTransferPalletBind:
|
||||
type: object
|
||||
required: [palletCode, idempotencyKey]
|
||||
properties:
|
||||
palletCode: { type: string, minLength: 1, maxLength: 128 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
WarehouseTransferPallet:
|
||||
type: object
|
||||
required: [id, sequenceNo, cartonCount, lengthCm, widthCm, heightCm, volumeCbm, grossWeightKg, status]
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
sequenceNo: { type: integer, minimum: 1 }
|
||||
warehousePalletId: { type: integer, format: int64, nullable: true }
|
||||
palletNo: { type: string, nullable: true, description: GENERATED后由Warehouse Overseas为本次测量新生成 }
|
||||
cartonCount: { type: integer, minimum: 1 }
|
||||
lengthCm: { type: number, format: double }
|
||||
widthCm: { type: number, format: double }
|
||||
heightCm: { type: number, format: double }
|
||||
volumeCbm: { type: number, format: double }
|
||||
grossWeightKg: { type: number, format: double }
|
||||
status: { type: string, enum: [DRAFT, GENERATED, BOUND, COMPLETED] }
|
||||
fbaWarehouseCode: { type: string, nullable: true, description: 为空时标签显示目的仓待定 }
|
||||
labelDownloadPath: { type: string, nullable: true, description: GENERATED后返回当前PDA鉴权下的真实PDF下载路径 }
|
||||
boundAt: { type: string, format: date-time, nullable: true }
|
||||
WarehouseTransferMeasurementPhoto:
|
||||
type: object
|
||||
required: [fileId, sizeBytes, uploadedAt]
|
||||
properties:
|
||||
fileId: { type: integer, format: int64 }
|
||||
fileName: { type: string, nullable: true }
|
||||
contentType: { type: string, nullable: true }
|
||||
sizeBytes: { type: integer, format: int64, minimum: 0 }
|
||||
uploadedAt: { type: string, format: date-time }
|
||||
StocktakeSummaryPageResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
@@ -732,7 +975,19 @@ components:
|
||||
source: { $ref: '#/components/schemas/PalletExpectation' }
|
||||
target: { $ref: '#/components/schemas/PalletExpectation' }
|
||||
cartonMarks: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string, maxLength: 255 } }
|
||||
actualSource: { $ref: '#/components/schemas/PalletMoveLine' }
|
||||
reason: { type: string, maxLength: 500 }
|
||||
RelabelSource:
|
||||
type: object
|
||||
properties:
|
||||
positionId: { type: string, pattern: '^[0-9]+$' }
|
||||
positionVersion: { type: integer, format: int64 }
|
||||
palletCode: { type: string }
|
||||
locationCode: { type: string, nullable: true }
|
||||
inboundCode: { type: string }
|
||||
stockPackages: { type: integer, format: int64 }
|
||||
movablePackages: { type: integer, format: int64 }
|
||||
cartonMarks: { type: array, items: { type: string } }
|
||||
PalletSwap:
|
||||
type: object
|
||||
required: [idempotencyKey, source, target]
|
||||
|
||||
+2
-1
@@ -67,6 +67,7 @@ class DeliveryLongIdJsonContractTest {
|
||||
null, null, "PS-1", null, null, null, null, "CREATED", null,
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
|
||||
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null);
|
||||
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null,
|
||||
null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
class DeliveryCompletedOutboxServiceTest {
|
||||
@Test
|
||||
@@ -48,6 +49,23 @@ class DeliveryCompletedOutboxServiceTest {
|
||||
.containsExactly("ONT8", "ACY2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsRelabelApplicationWithoutDomesticSourceOrder() {
|
||||
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
|
||||
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
|
||||
ReflectionTestUtils.setField(service, "outbox", mapper);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(1L); delivery.setDeliveryNo("DL-RELABEL"); delivery.setVersion(2L);
|
||||
LastlegOrder relabel = new LastlegOrder();
|
||||
relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION");
|
||||
relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1");
|
||||
|
||||
service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
|
||||
LastlegOrder value = new LastlegOrder();
|
||||
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
|
||||
|
||||
+25
-1
@@ -15,7 +15,8 @@ class DispatchCandidateMapperContractTest {
|
||||
"planNo", "orderType", "volumeWeightKg", "containerNo", "appointmentAt", "expectedLoadingAt",
|
||||
"overseasWarehouseId", "overseasWarehouseName", "orderNo", "orderRefs", "inboundCode", "palletNo",
|
||||
"destinationWarehouseId", "destinationWarehouseCode", "destinationWarehouseName", "fbaWarehouseCode",
|
||||
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks"
|
||||
"packageCount", "volumeCbm", "sourceStage", "cartonMarks", "relabelCartonMarks",
|
||||
"destinationType", "deliveryAddress", "postalCode", "contactName", "contactPhone"
|
||||
);
|
||||
|
||||
@Test
|
||||
@@ -37,6 +38,29 @@ class DispatchCandidateMapperContractTest {
|
||||
assertTrue(sql.contains("pos.available_packages > 0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidatesWithoutFbaWarehouseAreExcludedFromReadAndWriteQueries() throws Exception {
|
||||
for (Method method : List.of(
|
||||
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
|
||||
String.class, String.class, String.class),
|
||||
DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) {
|
||||
String sql = String.join(" ", method.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("NULLIF(TRIM(l.fba_warehouse_code),'') IS NOT NULL"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidateKeywordSearchIncludesSourceInboundCode() throws Exception {
|
||||
Method available = DispatchCandidateMapper.class.getMethod("available", String.class, List.class,
|
||||
String.class, String.class, String.class, String.class);
|
||||
String availableSql = String.join(" ", available.getAnnotation(Select.class).value());
|
||||
assertTrue(availableSql.contains("o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')"));
|
||||
|
||||
Method relabel = DispatchCandidateMapper.class.getMethod("relabelAvailable", String.class, String.class, String.class);
|
||||
String relabelSql = String.join(" ", relabel.getAnnotation(Select.class).value());
|
||||
assertTrue(relabelSql.contains("ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%')"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidateWarehouseComesFromPhysicalInventoryOrFrozenInboundPlan() throws Exception {
|
||||
for (Method method : List.of(
|
||||
|
||||
+24
@@ -190,6 +190,22 @@ class DispatchServiceTest {
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeRejectsPalletWithoutFbaWarehouse() {
|
||||
DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L,
|
||||
"WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001",
|
||||
null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason());
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
@@ -307,6 +323,14 @@ class DispatchServiceTest {
|
||||
verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void relabelSourceDoesNotRequireOrQueryAnInboundPlan() {
|
||||
when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of());
|
||||
org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty());
|
||||
verify(candidates).relabelAvailable(null, "RL-1", null);
|
||||
verify(candidates, never()).available(any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() {
|
||||
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100"));
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ class InboundPlanSchemaContractTest {
|
||||
assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)"));
|
||||
assertTrue(schema.contains("expected_loading_at"));
|
||||
assertTrue(schema.contains("planned_arrival_date"));
|
||||
assertTrue(schema.contains("creation_mode"));
|
||||
assertTrue(schema.contains("creation_mode varchar(32)"));
|
||||
assertTrue(schema.contains("modify column creation_mode varchar(32)"));
|
||||
assertTrue(schema.contains("pallet_type"));
|
||||
assertTrue(schema.contains("create table if not exists ll_order_command_record"));
|
||||
assertTrue(schema.contains("create table if not exists ll_order_hold_history"));
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ class PdaPalletOperationControllerContractTest {
|
||||
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
|
||||
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble",
|
||||
"/relabel-disassemble", "/swap", "/unbind"), paths);
|
||||
"/relabel-disassemble", "/relabel-sources", "/swap", "/unbind"), paths);
|
||||
}
|
||||
|
||||
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {
|
||||
|
||||
+107
-1
@@ -42,6 +42,17 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdaPalletOperationServiceTest {
|
||||
@Test void relabelRebindingRejectsDispatchAndOutboundOccupation() {
|
||||
RelabelApplication app = new RelabelApplication();
|
||||
for (String status : List.of("PRE_DISPATCH_CREATED", "OUTBOUND_CREATED", "OUTBOUNDED")) {
|
||||
app.setFulfillmentStatus(status);
|
||||
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
|
||||
}
|
||||
app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
assertDoesNotThrow(() -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
|
||||
app.setOutboundOrderNo("OUT-1");
|
||||
assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app));
|
||||
}
|
||||
@Mock private PdaPalletOperationMapper mapper;
|
||||
@Mock private InventoryPositionMapper positions;
|
||||
@Mock private InventoryBalanceMapper balances;
|
||||
@@ -185,6 +196,13 @@ class PdaPalletOperationServiceTest {
|
||||
}
|
||||
|
||||
@Test void relabelDisassembleMovesOnlySelectedHeldCartonWithoutRequiringLocations() {
|
||||
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
|
||||
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
|
||||
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask();
|
||||
task.setId(900L); task.setStatus("COMPLETED");
|
||||
when(tasks.find("100", 600L)).thenReturn(task);
|
||||
var sourcePallet = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null,
|
||||
"OCCUPIED", null, 1);
|
||||
var targetPallet = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null,
|
||||
@@ -196,7 +214,7 @@ class PdaPalletOperationServiceTest {
|
||||
|
||||
RelabelApplication application = new RelabelApplication(); application.setId(600L);
|
||||
application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND");
|
||||
application.setOverseasWarehouseId("88"); application.setOutboundOrderNo("OUT-1");
|
||||
application.setOverseasWarehouseId("88"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH");
|
||||
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
|
||||
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(application);
|
||||
RelabelItem selected = relabelItem(701L, "CARTON-A", 100L);
|
||||
@@ -266,6 +284,94 @@ class PdaPalletOperationServiceTest {
|
||||
relabelPalletMutations);
|
||||
}
|
||||
|
||||
@Test void actualSourceReconcilesOnlySelectedHoldAndKeepsOriginalInboundBalance() {
|
||||
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
|
||||
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
|
||||
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L); task.setStatus("COMPLETED");
|
||||
when(tasks.find("100", 600L)).thenReturn(task);
|
||||
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1");
|
||||
app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
|
||||
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
|
||||
when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app);
|
||||
RelabelItem item = relabelItem(701L, "CARTON-A", 101L);
|
||||
when(relabelItems.selectList(any())).thenReturn(List.of(item));
|
||||
when(relabelItems.selectById(701L)).thenReturn(item);
|
||||
InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L);
|
||||
InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L);
|
||||
AtomicReference<InventoryPosition> target = new AtomicReference<>();
|
||||
when(positions.selectById(anyLong())).thenAnswer(invocation -> {
|
||||
long id = invocation.getArgument(0); return id == 101L ? anchor : id == 100L ? actual : target.get();
|
||||
});
|
||||
doAnswer(invocation -> { target.set(invocation.getArgument(0)); return 1; }).when(positions).insert(any(InventoryPosition.class));
|
||||
when(mapper.lockPosition(eq("88"), anyLong())).thenAnswer(invocation -> {
|
||||
InventoryPosition p = ((Long) invocation.getArgument(1)) == 101L ? anchor : actual;
|
||||
PalletInventoryRow row = row(p.getId(), p.getPalletId(), p.getVersion(), p.getAvailablePackages());
|
||||
row.setHoldPackages(p.getHoldPackages()); return row;
|
||||
});
|
||||
var src = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, null, "OCCUPIED", null, 1);
|
||||
var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2);
|
||||
when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst);
|
||||
when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any()))
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
|
||||
when(mapper.content("88", 10L)).thenReturn(List.of());
|
||||
when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages());
|
||||
when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages());
|
||||
InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100");
|
||||
balance.setOverseasWarehouseId("88"); balance.setInboundCode("ORIGINAL-INBOUND");
|
||||
when(balances.selectById(200L)).thenReturn(balance);
|
||||
AtomicReference<PdaPalletOperation> op = new AtomicReference<>();
|
||||
doAnswer(invocation -> { op.set(invocation.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class));
|
||||
when(mapper.selectById(anyLong())).thenAnswer(invocation -> op.get());
|
||||
var result = service.relabelDisassemble(identity(), new PalletOperationRequests.RelabelDisassemble(
|
||||
"qty-1", "RL-1", new PalletOperationRequests.PalletExpectation("SOURCE", 1),
|
||||
new PalletOperationRequests.PalletExpectation("TARGET", 2), List.of("CARTON-A"), "quantity",
|
||||
new PalletOperationRequests.MoveLine(100L, 1L, 4L)));
|
||||
assertEquals("COMPLETED", result.status());
|
||||
assertEquals(1L, anchor.getHoldPackages()); // Other order's hold is not moved.
|
||||
assertEquals(1L, anchor.getAvailablePackages());
|
||||
assertEquals(4L, actual.getAvailablePackages()); assertEquals(0L, actual.getHoldPackages());
|
||||
assertEquals(1L, target.get().getHoldPackages()); assertEquals(200L, target.get().getBalanceId());
|
||||
assertEquals(7L, anchor.getAvailablePackages() + anchor.getHoldPackages() + actual.getAvailablePackages()
|
||||
+ actual.getHoldPackages() + target.get().getHoldPackages());
|
||||
verify(relabelPalletMutations).moveSelectedItems(eq(app), argThat(values -> values.get(100L).equals(List.of(item))), anyMap());
|
||||
ArgumentCaptor<InventoryLedger> entries = ArgumentCaptor.forClass(InventoryLedger.class);
|
||||
verify(ledgers, times(4)).insert(entries.capture());
|
||||
assertTrue(entries.getAllValues().stream().allMatch(entry -> "ORIGINAL-INBOUND".equals(entry.getInboundCode())));
|
||||
verify(balances, never()).updateById(any(InventoryBalance.class));
|
||||
}
|
||||
|
||||
@Test void boundMarksSurviveReloadButDoNotBlockANewReworkRound() {
|
||||
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
|
||||
var histories = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkHistory", histories);
|
||||
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
|
||||
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(900L);
|
||||
when(tasks.find("100", 600L)).thenReturn(task);
|
||||
var history = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
|
||||
history.setDetailJson("{\"round\":\"INITIAL\",\"cartonMarks\":[\"CARTON-A\"]}");
|
||||
when(histories.selectList(any())).thenReturn(List.of(history));
|
||||
assertEquals(java.util.Set.of("CARTON-A"), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
|
||||
task.setReviewedAt(LocalDateTime.of(2026, 9, 15, 12, 0));
|
||||
assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
|
||||
}
|
||||
|
||||
@Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() {
|
||||
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
|
||||
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
|
||||
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setRelabelNo("RL-1");
|
||||
app.setCustomerId("100"); app.setOverseasWarehouseId("88"); app.setStatus("PROCESSING");
|
||||
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
|
||||
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7");
|
||||
when(tasks.find("100", 600L)).thenReturn(task);
|
||||
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0));
|
||||
assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode());
|
||||
task.setOperatorKey("other");
|
||||
assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different")));
|
||||
}
|
||||
|
||||
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
|
||||
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
|
||||
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
|
||||
|
||||
+32
-1
@@ -61,6 +61,7 @@ class PdaRelabelServiceTest {
|
||||
void setUp() {
|
||||
service = new PdaRelabelService();
|
||||
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);
|
||||
ReflectionTestUtils.setField(service, "tasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "unitFacts", unitFacts);
|
||||
@@ -103,7 +104,7 @@ class PdaRelabelServiceTest {
|
||||
PdaRelabelTask active = task("PROCESSING", 0);
|
||||
active.setApplicationId(99L);
|
||||
active.setRelabelNo("FBR-ACTIVE");
|
||||
when(tasks.lockUnfinishedByOperator("7")).thenReturn(active);
|
||||
when(tasks.lockProcessingByOperator("7")).thenReturn(active);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-other")));
|
||||
@@ -205,6 +206,36 @@ class PdaRelabelServiceTest {
|
||||
assertEquals(1, result.version());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU"})
|
||||
void oldSkuOnlyScanPersistsEachRowAndCompletesBoxOnlyWhenRequiredStagesFinish(String type) {
|
||||
application.setStatus("PROCESSING");
|
||||
item.setOperationType(type); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1");
|
||||
item.setNotifiedPackageCount(1);
|
||||
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW-B\",\"quantity\":2}]");
|
||||
var task = task("PROCESSING", 0);
|
||||
var saved = new ArrayList<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>();
|
||||
when(histories.selectList(any())).thenAnswer(call -> List.copyOf(saved));
|
||||
when(histories.insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class))).thenAnswer(call -> { saved.add(call.getArgument(0)); return 1; });
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
lenient().when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
when(items.selectOne(any())).thenReturn(item);
|
||||
var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a"));
|
||||
assertEquals(0, first.relabelledPackages()); assertEquals(1, first.version());
|
||||
assertTrue(saved.stream().anyMatch(entry -> "SKU_SCAN".equals(entry.getAction()) && entry.getDetailJson().contains("NEW-A")));
|
||||
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 1, "duplicate")));
|
||||
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "WRONG", 1, "wrong")));
|
||||
var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b"));
|
||||
assertEquals(1, second.items().get(0).skuProcessedPackages());
|
||||
if ("CARTON_AND_SKU".equals(type)) {
|
||||
assertEquals(0, second.relabelledPackages());
|
||||
var last = service.scanCarton("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.CartonScan("BOX-1", 2, "carton-last"));
|
||||
assertEquals(1, last.relabelledPackages());
|
||||
} else assertEquals(1, second.relabelledPackages());
|
||||
assertEquals(1, persistedFacts.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() {
|
||||
application.setStatus("PROCESSING");
|
||||
|
||||
+136
-20
@@ -117,6 +117,7 @@ class RelabelApplicationServiceTest {
|
||||
lenient().when(feeSources.publishApproved(any(), any(), any())).thenReturn(new OverseasFeeSource());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void createBuildsPendingRelabelTailOrderItemsAndBothHistories() {
|
||||
RelabelRequests.Create request = request("key-1", 10, 6);
|
||||
@@ -144,7 +145,7 @@ class RelabelApplicationServiceTest {
|
||||
verify(orderHistories).insert(tailHistory.capture());
|
||||
|
||||
assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType());
|
||||
assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus());
|
||||
assertEquals("WAIT_RELABEL", tailOrder.getValue().getCurrentStatus());
|
||||
assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus());
|
||||
assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion());
|
||||
assertTrue(tailOrder.getValue().getOrderNo().matches("RL-WH-\\d{8}-\\d{6}"));
|
||||
@@ -155,10 +156,36 @@ class RelabelApplicationServiceTest {
|
||||
assertEquals(6, application.getValue().getNotifiedPackageCount());
|
||||
assertEquals("SKU-A", relabelItem.getValue().getSkuCode());
|
||||
assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus());
|
||||
assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus());
|
||||
assertEquals("WAIT_RELABEL", tailHistory.getValue().getToStatus());
|
||||
assertEquals("FBR-1", result.application().relabelNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void warehouseTransferRelabelReusesExistingTailOrder() {
|
||||
RelabelRequests.Create request = request("key-transfer", 10, 6);
|
||||
RelabelApplication persisted = application(11L, "FBR-TRANSFER", "10001", "key-transfer", null,
|
||||
RelabelApplicationService.PENDING_PROCESSING);
|
||||
persisted.setLastlegOrderId(21L);
|
||||
LastlegOrder transferOrder = order(21L, "10001", "WT-WH-20260916-560449", "INBOUND_COMPLETED");
|
||||
transferOrder.setOrderType("WAREHOUSE_TRANSFER");
|
||||
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
|
||||
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6)));
|
||||
when(histories.selectList(any())).thenReturn(List.of());
|
||||
when(orders.selectById(21L)).thenReturn(transferOrder);
|
||||
|
||||
service.createForExistingTailOrder("10001", "operator-1", 21L, request);
|
||||
|
||||
ArgumentCaptor<RelabelApplication> application = ArgumentCaptor.forClass(RelabelApplication.class);
|
||||
verify(applications).insert(application.capture());
|
||||
verify(orders, never()).insert(any(LastlegOrder.class));
|
||||
verify(orderHistories, never()).insert(any(LastlegOrderStatusHistory.class));
|
||||
assertEquals(21L, application.getValue().getLastlegOrderId());
|
||||
assertEquals(application.getValue().getRelabelNo(), transferOrder.getRelabelOrderNo());
|
||||
assertEquals(RelabelApplicationService.PENDING_PROCESSING, transferOrder.getRelabelStatus());
|
||||
assertEquals("WAREHOUSE_TRANSFER", transferOrder.getOrderType());
|
||||
assertEquals("INBOUND_COMPLETED", transferOrder.getCurrentStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithoutChargeSkipsWalletFreeze() {
|
||||
RelabelRequests.Create charged = request("key-free", 10, 6);
|
||||
@@ -242,27 +269,19 @@ class RelabelApplicationServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void createKeepsOneOperationWarehouseWhenItemsHaveDifferentFbaDestinations() {
|
||||
void createRejectsDifferentFbaDestinationsInOneApplication() {
|
||||
RelabelRequests.Item west = carton("SKU-A", "BOX-1", "FBA-1", "ONT8");
|
||||
RelabelRequests.Item east = carton("SKU-B", "BOX-2", "FBA-2", "ABE8");
|
||||
RelabelRequests.Create request = new RelabelRequests.Create("CUSTOMER-1", "WH-1",
|
||||
LocalDateTime.of(2026, 8, 20, 10, 0), "Li", "13800000000", BigDecimal.ONE, null, "key-multi",
|
||||
List.of(west, east));
|
||||
RelabelApplication persisted = application(11L, "FBR-1", "10001", "key-multi", null,
|
||||
RelabelApplicationService.PENDING_PROCESSING);
|
||||
persisted.setLastlegOrderId(21L);
|
||||
when(applications.findNo(eq("10001"), any())).thenReturn(persisted);
|
||||
when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 1, 1)));
|
||||
when(histories.selectList(any())).thenReturn(List.of());
|
||||
when(orders.selectById(21L)).thenReturn(order(21L, "10001", "LMO-RELABEL-1",
|
||||
RelabelApplicationService.PENDING_PROCESSING));
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.create("10001", "operator-1", request));
|
||||
|
||||
service.create("10001", "operator-1", request);
|
||||
|
||||
ArgumentCaptor<LastlegOrder> created = ArgumentCaptor.forClass(LastlegOrder.class);
|
||||
verify(orders).insert(created.capture());
|
||||
assertEquals("WH-1", created.getValue().getOverseasWarehouseId());
|
||||
assertEquals(2, created.getValue().getPackageCount());
|
||||
assertEquals(400, exception.getStatusCode().value());
|
||||
assertEquals("同一张贴标申请只能选择一个 FBA 目的仓", exception.getReason());
|
||||
verify(orders, never()).insert(any(LastlegOrder.class));
|
||||
verify(applications, never()).insert(any(RelabelApplication.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -363,6 +382,9 @@ class RelabelApplicationServiceTest {
|
||||
application.setLastlegOrderId(21L);
|
||||
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
|
||||
stubDetail(application);
|
||||
LastlegOrder relabelOrder = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT");
|
||||
relabelOrder.setOrderType("RELABEL_APPLICATION");
|
||||
when(orders.selectById(21L)).thenReturn(relabelOrder);
|
||||
|
||||
RelabelResponses.Detail result = service.changeStatus("10001", "FBR-1", "operator-2",
|
||||
new RelabelRequests.ChangeStatus("processing", "started"));
|
||||
@@ -372,12 +394,33 @@ class RelabelApplicationServiceTest {
|
||||
verify(applications).updateById(application);
|
||||
ArgumentCaptor<LastlegOrder> updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class);
|
||||
verify(orders).updateById(updatedOrder.capture());
|
||||
assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus());
|
||||
assertEquals("WAIT_RELABEL", updatedOrder.getValue().getCurrentStatus());
|
||||
assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus());
|
||||
verify(histories).insert(any(RelabelStatusHistory.class));
|
||||
verify(orderHistories).insert(any(LastlegOrderStatusHistory.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewStageIsReportedAsCompletedOnLinkedCustomerAndPlatformOrder() {
|
||||
RelabelApplication application = application(11L, "FBR-REVIEW", "10001", "key-review", "hash",
|
||||
"PROCESSING");
|
||||
application.setLastlegOrderId(21L);
|
||||
LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PENDING_ASSIGNMENT");
|
||||
when(applications.lockNo("10001", "FBR-REVIEW")).thenReturn(application);
|
||||
when(applications.findNo("10001", "FBR-REVIEW")).thenReturn(application);
|
||||
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1)));
|
||||
when(histories.selectList(any())).thenReturn(List.of());
|
||||
when(orders.selectById(21L)).thenReturn(order);
|
||||
|
||||
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
|
||||
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
|
||||
|
||||
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, application.getStatus());
|
||||
assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus());
|
||||
assertEquals("COMPLETED", order.getRelabelStatus());
|
||||
assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() {
|
||||
RelabelApplication completed = application(11L, "FBR-1", "10001", "key-1", "hash", "COMPLETED");
|
||||
@@ -466,7 +509,7 @@ class RelabelApplicationServiceTest {
|
||||
var review = ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask.class);
|
||||
verify(pdaTasks).updateWithVersion(review.capture(), eq(1));
|
||||
assertEquals("COMPLETED", review.getValue().getStatus());
|
||||
assertEquals("CUSTOMER_CONFIRMED", review.getValue().getReviewDecision());
|
||||
assertEquals("CONFIRMED", review.getValue().getReviewDecision());
|
||||
assertNull(application.getOutboundOrderNo());
|
||||
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
||||
|
||||
@@ -475,12 +518,36 @@ class RelabelApplicationServiceTest {
|
||||
verify(applications, times(1)).updateById(application);
|
||||
}
|
||||
|
||||
@Test
|
||||
void skuOnlyCustomerConfirmationCompletesWithoutPalletOrOutboundFulfillment() {
|
||||
RelabelApplication application = confirmationApplication(null, false);
|
||||
RelabelItem skuOnly = item(31L, application.getId(), "SKU-A", 1, 1);
|
||||
skuOnly.setOperationType("SKU_LABEL");
|
||||
LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL");
|
||||
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
|
||||
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
|
||||
when(items.selectList(any())).thenReturn(List.of(skuOnly));
|
||||
when(histories.selectList(any())).thenReturn(List.of());
|
||||
when(orders.selectById(21L)).thenReturn(linked);
|
||||
|
||||
service.confirmCompletion("10001", "FBR-1", "customer-1",
|
||||
new RelabelRequests.CustomerConfirm("confirm-sku", "accepted"));
|
||||
|
||||
assertEquals("COMPLETED", application.getStatus());
|
||||
assertEquals("NOT_REQUIRED", application.getFulfillmentStatus());
|
||||
assertEquals("COMPLETED", linked.getCurrentStatus());
|
||||
assertNull(application.getOutboundOrderNo());
|
||||
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
||||
verify(outboundItems, never()).insert(any(OutboundPalletItem.class));
|
||||
verify(outboundTasks, never()).insert(any(OutboundPdaTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferRelabelConfirmationReleasesTransferForDispatch() {
|
||||
RelabelApplication application = confirmationApplication("TRUCK", false);
|
||||
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
|
||||
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
|
||||
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
|
||||
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8");
|
||||
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
|
||||
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
|
||||
stubDetail(application);
|
||||
@@ -498,6 +565,55 @@ class RelabelApplicationServiceTest {
|
||||
verify(orders).updateById(transferOrder);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferRelabelConfirmationWithoutDestinationWaitsForFbaInsteadOfDispatch() {
|
||||
RelabelApplication application = confirmationApplication("TRUCK", false);
|
||||
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
|
||||
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
|
||||
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
|
||||
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
|
||||
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
|
||||
stubDetail(application);
|
||||
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(88L)).thenReturn(transferOrder);
|
||||
|
||||
service.confirmCompletion("10001", "FBR-1", "customer-1",
|
||||
new RelabelRequests.CustomerConfirm("confirm-transfer-no-fba", "accepted"));
|
||||
|
||||
assertEquals("WAIT_FBA_DESTINATION", transfer.getStatus());
|
||||
assertEquals("WAIT_FBA_DESTINATION", transferOrder.getCurrentStatus());
|
||||
assertEquals("WAREHOUSE_TRANSFER:WAIT_FBA_DESTINATION;RELABEL:COMPLETED",
|
||||
transferOrder.getSubOrderStatus());
|
||||
assertEquals("WAIT_FBA_DESTINATION", application.getFulfillmentStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferRelabelConfirmationDerivesUniqueDestinationFromRelabelItems() {
|
||||
RelabelApplication application = confirmationApplication("TRUCK", false);
|
||||
WarehouseTransferOrder transfer = new WarehouseTransferOrder();
|
||||
transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1");
|
||||
transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L);
|
||||
LastlegOrder transferOrder = order(88L, "10001", "LMO-TRANSFER-1", "WAIT_RELABEL");
|
||||
RelabelItem relabelItem = item(31L, application.getId(), "SKU-A", 10, 6);
|
||||
relabelItem.setDestinationFbaWarehouseCode("ONT8");
|
||||
when(applications.lockNo("10001", "FBR-1")).thenReturn(application);
|
||||
when(applications.findNo("10001", "FBR-1")).thenReturn(application);
|
||||
when(items.selectList(any())).thenReturn(List.of(relabelItem));
|
||||
when(histories.selectList(any())).thenReturn(List.of());
|
||||
when(warehouseTransfers.lockByRelabelNo("10001", "FBR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(88L)).thenReturn(transferOrder);
|
||||
when(orders.selectById(application.getLastlegOrderId())).thenReturn(
|
||||
order(application.getLastlegOrderId(), "10001", "LMO-RELABEL-1", application.getStatus()));
|
||||
|
||||
service.confirmCompletion("10001", "FBR-1", "customer-1",
|
||||
new RelabelRequests.CustomerConfirm("confirm-transfer-derived-fba", "accepted"));
|
||||
|
||||
assertEquals("ONT8", transfer.getFbaWarehouseCode());
|
||||
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
|
||||
assertEquals("ONT8", transferOrder.getFbaWarehouseCode());
|
||||
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expressCustomerConfirmationRequiresShippingLabelWhenWarehousePrintingIsSelected() {
|
||||
RelabelApplication application = confirmationApplication("EXPRESS", true);
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.lastleg.relabel.service;
|
||||
|
||||
import com.baiyue.lastleg.relabel.entity.*;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RelabelBindingGuardTest {
|
||||
@Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() {
|
||||
var guard = new RelabelBindingGuard();
|
||||
var items = mock(RelabelItemMapper.class);
|
||||
var history = mock(PdaRelabelHistoryMapper.class);
|
||||
var positions = mock(InventoryPositionMapper.class);
|
||||
ReflectionTestUtils.setField(guard, "items", items);
|
||||
ReflectionTestUtils.setField(guard, "histories", history);
|
||||
ReflectionTestUtils.setField(guard, "positions", positions);
|
||||
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
|
||||
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
|
||||
var item = new RelabelItem(); item.setCartonMark("A"); item.setOperationType("CARTON_LABEL");
|
||||
item.setSourceInventoryPositionId(3L); item.setNotifiedPackageCount(1);
|
||||
when(items.selectList(any())).thenReturn(List.of(item));
|
||||
var position = new InventoryPosition(); position.setPalletNo("OLD"); position.setHoldPackages(1L);
|
||||
when(positions.findTenantPosition("2", 3L)).thenReturn(position);
|
||||
when(history.selectList(any())).thenReturn(List.of());
|
||||
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
|
||||
var binding = new PdaRelabelHistory(); binding.setAction("PALLET_BIND");
|
||||
binding.setDetailJson("{\"cartonMarks\":[\"A\"],\"targetPallets\":[\"NEW\"]}");
|
||||
when(history.selectList(any())).thenReturn(List.of(binding));
|
||||
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
|
||||
position.setPalletNo("NEW");
|
||||
assertDoesNotThrow(() -> guard.requireBound(app));
|
||||
var rework = new PdaRelabelHistory(); rework.setAction("REWORK_ARCHIVE");
|
||||
when(history.selectList(any())).thenReturn(List.of(binding, rework));
|
||||
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
|
||||
item.setOperationType("SKU_LABEL");
|
||||
assertDoesNotThrow(() -> guard.requireBound(app));
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.relabel.service;
|
||||
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
|
||||
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RelabelReturnCartonCandidateTest {
|
||||
@Test
|
||||
void candidatesIncludeAvailableMergedReturnCartonByWarehouseMark() {
|
||||
RelabelApplicationService service = new RelabelApplicationService();
|
||||
InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
|
||||
InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
|
||||
ReturnInventoryCartonMapper cartons = mock(ReturnInventoryCartonMapper.class);
|
||||
ReturnInventoryCartonItemMapper cartonItems = mock(ReturnInventoryCartonItemMapper.class);
|
||||
ReflectionTestUtils.setField(service, "inventoryBalances", balances);
|
||||
ReflectionTestUtils.setField(service, "inventoryPositions", positions);
|
||||
ReflectionTestUtils.setField(service, "returnCartons", cartons);
|
||||
ReflectionTestUtils.setField(service, "returnCartonItems", cartonItems);
|
||||
|
||||
ReturnInventoryCarton carton = new ReturnInventoryCarton();
|
||||
carton.setId(9001L); carton.setCustomerId("10001"); carton.setOverseasWarehouseId("WH-1");
|
||||
carton.setCartonMark("RC-WH-0001"); carton.setSourceReturnNo("RT-1,RT-2");
|
||||
carton.setStatus("AVAILABLE"); carton.setTotalQuantity(12L);
|
||||
ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem();
|
||||
cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN");
|
||||
cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L);
|
||||
when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton));
|
||||
when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem));
|
||||
|
||||
List<RelabelResponses.InventoryCandidate> result = service.candidates("10001", "WH-1", "RC-WH");
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("RETURN_CARTON", result.get(0).sourceType());
|
||||
assertEquals(9001L, result.get(0).returnCartonId());
|
||||
assertEquals("RC-WH-0001", result.get(0).cartonMark());
|
||||
assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
|
||||
assertEquals(12, result.get(0).skuItems().get(0).quantity());
|
||||
}
|
||||
}
|
||||
+22
@@ -11,6 +11,8 @@ class WarehouseTransferSchemaContractTest {
|
||||
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception {
|
||||
String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
|
||||
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
|
||||
String deployment = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8).toLowerCase();
|
||||
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order",
|
||||
"ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation",
|
||||
"ll_warehouse_transfer_receipt"}) {
|
||||
@@ -21,5 +23,25 @@ class WarehouseTransferSchemaContractTest {
|
||||
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
|
||||
assertTrue(migration.contains("uk_ll_wh_transfer_active_position"));
|
||||
assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source"));
|
||||
assertTrue(baseline.contains("pickup_contact_name varchar(128) null"));
|
||||
assertTrue(baseline.contains("pickup_contact_phone varchar(64) null"));
|
||||
assertTrue(migration.contains("column_name='pickup_contact_name'"));
|
||||
assertTrue(migration.contains("column_name='pickup_contact_phone'"));
|
||||
assertTrue(baseline.contains("create table if not exists ll_warehouse_transfer_measurement_photo"));
|
||||
assertTrue(migration.contains("create table if not exists ll_warehouse_transfer_measurement_photo"));
|
||||
assertTrue(baseline.contains("received_package_count int null comment 'pda实际入库箱数'"));
|
||||
assertTrue(migration.contains("column_name='received_package_count'"));
|
||||
assertTrue(migration.contains("o.current_status='inbound_completed'"));
|
||||
for (String field : new String[]{"pickup_contact_name", "review_decision", "destination_type",
|
||||
"destination_binding_idempotency_key", "private_destination_address",
|
||||
"private_destination_postal_code", "draft_idempotency_key", "bind_idempotency_key"}) {
|
||||
assertTrue(deployment.contains(field), () -> "deployment SQL must include " + field);
|
||||
}
|
||||
for (String table : new String[]{"ll_warehouse_transfer_measurement_photo", "ll_inbound_plan_line",
|
||||
"ll_inbound_plan_pallet", "ll_dispatch_item", "ll_delivery_order"}) {
|
||||
assertTrue(deployment.contains(table), () -> "deployment SQL must include " + table);
|
||||
}
|
||||
assertTrue(deployment.contains("assert_relabel_transfer_latest_schema_ready"));
|
||||
assertTrue(deployment.contains("贴标申请与仓储转运最新表结构已完整部署"));
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -31,6 +31,25 @@ class MerchantWarehouseTransferControllerTest {
|
||||
verify(service).detail("100", "TR001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantSubmitUsesOnlyAuthenticatedTenant() {
|
||||
controller.submit(user(100L, "MERCHANT"), "TR001");
|
||||
verify(service).submit("100", "TR001", "1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantUpdateUsesOnlyAuthenticatedTenant() {
|
||||
WarehouseTransferRequests.Create request = mock(WarehouseTransferRequests.Create.class);
|
||||
controller.updateDraft(user(100L, "MERCHANT"), "TR001", request);
|
||||
verify(service).updateDraft("100", "TR001", "1", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantPrintsSubmittedCartonLabelsWithinAuthenticatedTenant() {
|
||||
controller.cartonLabel(user(100L, "MERCHANT"), "TR001");
|
||||
verify(service).cartonLabel("100", "TR001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantEndpointRejectsPlatformIdentity() {
|
||||
assertThrows(ResponseStatusException.class,
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.lastleg.transfer.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaWarehouseTransferControllerContractTest {
|
||||
@Test
|
||||
void detailUsesOnlyAuthenticatedPdaWarehouseScopeAndTransferNumber() throws Exception {
|
||||
var method = PdaWarehouseTransferController.class
|
||||
.getMethod("detail", AuthenticatedUser.class, String.class);
|
||||
|
||||
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{transferNo}");
|
||||
assertThat(method.getParameterCount()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void measurementPhotoUploadUsesThePublicPdaRouteAndFileMultipartPart() throws Exception {
|
||||
var method = PdaWarehouseTransferController.class.getMethod("uploadMeasurementPhoto",
|
||||
AuthenticatedUser.class, String.class, MultipartFile.class);
|
||||
|
||||
var mapping = method.getAnnotation(PostMapping.class);
|
||||
assertThat(mapping.value()).containsExactly("/{transferNo}/measurement-photos/files");
|
||||
assertThat(mapping.consumes()).containsExactly("multipart/form-data");
|
||||
assertThat(method.getParameters()[2].getAnnotation(RequestParam.class).value()).isEqualTo("file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void palletLabelUsesThePdaScopedTransferAndGeneratedWarehousePallet() throws Exception {
|
||||
var method = PdaWarehouseTransferController.class.getMethod("palletLabel",
|
||||
AuthenticatedUser.class, String.class, long.class, String.class);
|
||||
|
||||
assertThat(method.getAnnotation(GetMapping.class).value())
|
||||
.containsExactly("/{transferNo}/pallets/{warehousePalletId}/label");
|
||||
assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value()).isEqualTo("X-Request-Id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void palletLifecycleExposesDraftGenerateBindAndDraftDeleteRoutes() throws Exception {
|
||||
var draft = PdaWarehouseTransferController.class.getMethod("savePalletDraft",
|
||||
AuthenticatedUser.class, String.class, WarehouseTransferRequests.PalletDraft.class);
|
||||
var generate = PdaWarehouseTransferController.class.getMethod("generatePallet",
|
||||
AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletAction.class);
|
||||
var bind = PdaWarehouseTransferController.class.getMethod("bindPallet",
|
||||
AuthenticatedUser.class, String.class, long.class, WarehouseTransferRequests.PalletBind.class);
|
||||
var delete = PdaWarehouseTransferController.class.getMethod("deletePalletDraft",
|
||||
AuthenticatedUser.class, String.class, long.class);
|
||||
|
||||
assertThat(draft.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/drafts");
|
||||
assertThat(generate.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/generate");
|
||||
assertThat(bind.getAnnotation(PostMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}/bind");
|
||||
assertThat(delete.getAnnotation(DeleteMapping.class).value()).containsExactly("/{transferNo}/pallets/{palletId}");
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.transfer.dto;
|
||||
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WarehouseTransferCargoValidationTest {
|
||||
@Test
|
||||
void transferCargoAllowsSkuAndProductNameToBeOmitted() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var violations = factory.getValidator().validate(
|
||||
new WarehouseTransferRequests.Item(null, null, 1, null));
|
||||
|
||||
assertTrue(violations.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cartonRelabelAllowsOriginalSkuToBeOmitted() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var violations = factory.getValidator().validate(new WarehouseTransferRequests.RelabelItem(
|
||||
"CARTON_LABEL", List.of("CUSTOMER-CARTON-1"), null, null, 1,
|
||||
"FBA-CARTON-1", "ONT8", null));
|
||||
|
||||
assertTrue(violations.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -11,12 +11,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WarehouseTransferPalletValidationTest {
|
||||
@Test
|
||||
void palletAcceptsDirectTotalVolumeAndWeightWithoutCartonDimensions() {
|
||||
void palletAcceptsMeasuredDimensionsVolumeAndWeight() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
|
||||
new BigDecimal("1.250000"), new BigDecimal("420.50"),
|
||||
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
|
||||
var pallet = new WarehouseTransferRequests.Pallet(5,
|
||||
new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"),
|
||||
new BigDecimal("1.250000"), new BigDecimal("420.50"));
|
||||
|
||||
assertTrue(validator.validate(pallet).isEmpty());
|
||||
assertEquals(new BigDecimal("1.250000"), pallet.volumeCbm());
|
||||
@@ -28,11 +28,23 @@ class WarehouseTransferPalletValidationTest {
|
||||
void palletRejectsNonPositiveTotalVolume() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
var pallet = new WarehouseTransferRequests.Pallet("PALLET-1", 5,
|
||||
BigDecimal.ZERO, new BigDecimal("420.50"),
|
||||
List.of(new WarehouseTransferRequests.PalletItem(101L, 5)));
|
||||
var pallet = new WarehouseTransferRequests.Pallet(5,
|
||||
new BigDecimal("100"), new BigDecimal("100"), new BigDecimal("125"),
|
||||
BigDecimal.ZERO, new BigDecimal("420.50"));
|
||||
|
||||
assertEquals(1, validator.validate(pallet).size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void palletRejectsMissingOrNonPositiveDimensions() {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
var pallet = new WarehouseTransferRequests.Pallet(5,
|
||||
BigDecimal.ZERO, null, new BigDecimal("125"),
|
||||
new BigDecimal("1.250000"), new BigDecimal("420.50"));
|
||||
|
||||
assertEquals(2, validator.validate(pallet).size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.lastleg.transfer.gateway;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HttpTransferMeasurementPhotoFileGatewayContractTest {
|
||||
@Test
|
||||
void defaultsToTheFileServicesActualLocalPort() throws Exception {
|
||||
var field = HttpTransferMeasurementPhotoFileGateway.class.getDeclaredField("baseUrl");
|
||||
|
||||
assertThat(field.getAnnotation(Value.class).value())
|
||||
.isEqualTo("${baiyue.clients.file.url:http://127.0.0.1:9206}");
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class WarehouseTransferPalletAllocationTest {
|
||||
@Test
|
||||
void allocatesMultipleDeclaredSkusAcrossPalletsInStableDeclaredOrder() {
|
||||
WarehouseTransferItem first = item(11L, 3);
|
||||
WarehouseTransferItem second = item(12L, 4);
|
||||
var allocations = WarehouseTransferService.derivePalletAllocations(
|
||||
List.of(first, second), List.of(pallet(2), pallet(5)));
|
||||
|
||||
assertEquals(List.of(
|
||||
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 2),
|
||||
new WarehouseTransferService.DerivedPalletAllocation(1, 11L, 1),
|
||||
new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 4)), allocations);
|
||||
}
|
||||
|
||||
@Test
|
||||
void underReceiptConsumesDeclaredSkusInStableOrder() {
|
||||
var allocations = WarehouseTransferService.derivePalletAllocations(
|
||||
List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4)));
|
||||
|
||||
assertEquals(List.of(
|
||||
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3),
|
||||
new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1)), allocations);
|
||||
assertEquals(4, allocations.stream().mapToInt(value -> value.packageCount()).sum());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overReceiptAssignsExcessToFinalDeclaredSkuAcrossPallets() {
|
||||
var allocations = WarehouseTransferService.derivePalletAllocations(
|
||||
List.of(item(11L, 3), item(12L, 2)), List.of(pallet(4), pallet(3)));
|
||||
|
||||
assertEquals(List.of(
|
||||
new WarehouseTransferService.DerivedPalletAllocation(0, 11L, 3),
|
||||
new WarehouseTransferService.DerivedPalletAllocation(0, 12L, 1),
|
||||
new WarehouseTransferService.DerivedPalletAllocation(1, 12L, 3)), allocations);
|
||||
assertEquals(7, allocations.stream().mapToInt(value -> value.packageCount()).sum());
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestsFreshPalletNumbersAndCarriesOnlyTheKnownFbaDestination() {
|
||||
var known = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-1", "ONT8", 2);
|
||||
var unknown = WarehouseTransferService.newTransferPalletRequests(81L, "WTR-2", null, 1);
|
||||
|
||||
assertEquals(List.of("WTR-1:1", "WTR-1:2"), known.stream().map(value -> value.businessKey()).toList());
|
||||
assertEquals(List.of("ONT8", "ONT8"), known.stream().map(value -> value.fbaWarehouseCode()).toList());
|
||||
assertEquals(true, known.stream().allMatch(value -> value.palletCode() == null));
|
||||
assertEquals(null, unknown.get(0).fbaWarehouseCode());
|
||||
assertEquals(null, unknown.get(0).palletCode());
|
||||
}
|
||||
|
||||
private static WarehouseTransferItem item(long id, int packages) {
|
||||
WarehouseTransferItem item = new WarehouseTransferItem();
|
||||
item.setId(id); item.setPackageCount(packages);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static WarehouseTransferRequests.Pallet pallet(int cartons) {
|
||||
return new WarehouseTransferRequests.Pallet(cartons, BigDecimal.ONE, BigDecimal.ONE,
|
||||
BigDecimal.ONE, new BigDecimal("0.000001"), BigDecimal.ONE);
|
||||
}
|
||||
|
||||
private static com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet persistedPallet(int cartons) {
|
||||
var pallet = new com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet();
|
||||
pallet.setCartonCount(cartons);
|
||||
return pallet;
|
||||
}
|
||||
}
|
||||
+393
-12
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegInboundOrder;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||
@@ -19,11 +20,27 @@ import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferCarton;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferMeasurementPhoto;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferPallet;
|
||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferCartonMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper;
|
||||
import com.baiyue.lastleg.transfer.gateway.TransferMeasurementPhotoFileGateway;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -62,14 +79,36 @@ import static org.mockito.Mockito.times;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehouseTransferServiceTest {
|
||||
@Test
|
||||
void customerMainStatusFiltersExpandToInternalWorkflowStates() {
|
||||
assertEquals(List.of(WarehouseTransferService.APPROVED, WarehouseTransferService.WAIT_PDA_RECEIVING),
|
||||
WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_MEASURING));
|
||||
assertTrue(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.INBOUND_COMPLETED)
|
||||
.containsAll(List.of(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION,
|
||||
WarehouseTransferService.WAIT_FBA_DESTINATION, WarehouseTransferService.WAIT_RELABEL,
|
||||
WarehouseTransferService.READY_FOR_DISPATCH, WarehouseTransferService.OUTBOUNDED)));
|
||||
assertNull(WarehouseTransferService.customerMainStatuses(WarehouseTransferService.DRAFT));
|
||||
}
|
||||
|
||||
@Mock WarehouseTransferOrderMapper transfers;
|
||||
@Mock WarehouseTransferItemMapper transferItems;
|
||||
@Mock WarehouseTransferCartonMapper transferCartons;
|
||||
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
|
||||
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
|
||||
@Mock WarehouseTransferReceiptMapper transferReceipts;
|
||||
@Mock WarehouseTransferMeasurementPhotoMapper measurementPhotos;
|
||||
@Mock WarehouseTransferPalletMapper transferPallets;
|
||||
@Mock WarehouseTransferPalletItemMapper transferPalletItems;
|
||||
@Mock InboundStockReceiptMapper stockReceipts;
|
||||
@Mock WarehouseTransferRelabelBindingMapper relabelBindings;
|
||||
@Mock WarehouseResourceGateway warehouseResources;
|
||||
@Mock TransferMeasurementPhotoFileGateway measurementPhotoFiles;
|
||||
@Mock InboundSnapshotMapper inboundSnapshots;
|
||||
@Mock LastlegInboundOrderMapper inboundOrders;
|
||||
@Mock InboundPlanMapper inboundPlans;
|
||||
@Mock InboundPlanLineMapper inboundPlanLines;
|
||||
@Mock InboundPlanPalletMapper inboundPlanPallets;
|
||||
@Mock InboundPlanPalletAllocationMapper inboundPlanAllocations;
|
||||
@Mock LastlegOrderMapper orders;
|
||||
@Mock LastlegOrderDetailMapper orderDetails;
|
||||
@Mock LastlegOrderStatusHistoryMapper orderHistories;
|
||||
@@ -88,8 +127,19 @@ class WarehouseTransferServiceTest {
|
||||
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
|
||||
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
|
||||
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
|
||||
ReflectionTestUtils.setField(service, "measurementPhotos", measurementPhotos);
|
||||
ReflectionTestUtils.setField(service, "transferPallets", transferPallets);
|
||||
ReflectionTestUtils.setField(service, "transferPalletItems", transferPalletItems);
|
||||
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
|
||||
ReflectionTestUtils.setField(service, "relabelBindings", relabelBindings);
|
||||
ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources);
|
||||
ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles);
|
||||
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
|
||||
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
|
||||
ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans);
|
||||
ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines);
|
||||
ReflectionTestUtils.setField(service, "inboundPlanPallets", inboundPlanPallets);
|
||||
ReflectionTestUtils.setField(service, "inboundPlanAllocations", inboundPlanAllocations);
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
||||
ReflectionTestUtils.setField(service, "orderHistories", orderHistories);
|
||||
@@ -103,6 +153,116 @@ class WarehouseTransferServiceTest {
|
||||
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of());
|
||||
lenient().when(measurementPhotos.listOrder(anyString(), anyLong())).thenReturn(List.of());
|
||||
lenient().when(measurementPhotos.listSubmitted(anyString(), anyLong())).thenReturn(List.of());
|
||||
lenient().when(transferPalletItems.listOrder(anyString(), anyLong())).thenReturn(List.of());
|
||||
lenient().when(relabelBindings.listOrder(anyString(), anyLong())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedPalletRetryWithSameKeyDoesNotGenerateAnotherPhysicalPallet() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||
transfer.setOperatingWarehouseId("77");
|
||||
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
|
||||
pallet.setGenerateIdempotencyKey("GEN-1");
|
||||
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
|
||||
when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
|
||||
|
||||
service.generatePallet(77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletAction("GEN-1"));
|
||||
|
||||
verify(warehouseResources, never()).ensurePalletBatch(any());
|
||||
verify(warehouseResources, never()).confirmPalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scanBindingRejectsCodeOtherThanThisGeneratedPallet() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
|
||||
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
|
||||
when(transferPallets.lockOne("TENANT-A", transfer.getId(), pallet.getId())).thenReturn(pallet);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.bindPallet(
|
||||
77L, "WTR-1", pallet.getId(), new WarehouseTransferRequests.PalletBind("OTHER-PALLET", "BIND-1")));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(warehouseResources, never()).resolvePallet(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void palletizeRejectsWhenAnyPersistedPalletIsNotBound() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||
transfer.setOperatingWarehouseId("77");
|
||||
WarehouseTransferPallet pallet = measuredPallet("GENERATED");
|
||||
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of(transferItem(51L, "SKU-1", 5)));
|
||||
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
|
||||
photo.setFileId(9001L);
|
||||
when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo);
|
||||
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.palletize(
|
||||
"OP-1", 77L, "WTR-1", new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L))));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(warehouseResources, never()).ensurePalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void palletizeCompletesAfterEveryPalletIsBoundAndDoesNotGenerateAgain() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_PDA_RECEIVING);
|
||||
transfer.setOperatingWarehouseId("77");
|
||||
transfer.setInboundCode("WTR-1");
|
||||
WarehouseTransferPallet pallet = measuredPallet("BOUND");
|
||||
WarehouseTransferItem item = transferItem(51L, "SKU-1", 5);
|
||||
LastlegOrder tail = tailOrder();
|
||||
LastlegInboundOrder inbound = new LastlegInboundOrder();
|
||||
inbound.setId(transfer.getInboundOrderId()); inbound.setCustomerId("TENANT-A");
|
||||
InboundPlan inboundPlan = new InboundPlan(); inboundPlan.setId(201L); inboundPlan.setStatus("PDA_PENDING");
|
||||
InboundPlanLine inboundLine = new InboundPlanLine(); inboundLine.setId(202L);
|
||||
when(transfers.lockForPda("WTR-1", "77")).thenReturn(transfer);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of(item));
|
||||
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(pallet));
|
||||
WarehouseTransferMeasurementPhoto photo = new WarehouseTransferMeasurementPhoto();
|
||||
photo.setFileId(9001L);
|
||||
when(measurementPhotos.findFile("TENANT-A", transfer.getId(), 9001L)).thenReturn(photo);
|
||||
when(measurementPhotos.markSubmitted(eq("TENANT-A"), eq(transfer.getId()), any(), any())).thenReturn(1);
|
||||
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(tail);
|
||||
when(inboundOrders.selectById(transfer.getInboundOrderId())).thenReturn(inbound);
|
||||
when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(inboundPlan);
|
||||
when(inboundPlanLines.selectOne(any())).thenReturn(inboundLine);
|
||||
when(warehouseResources.requireActiveSharedWarehouse(77L)).thenReturn(
|
||||
new WarehouseResourceGateway.WarehouseReference(77L, "WH-77", "Warehouse 77", "ACTIVE"));
|
||||
when(inventory.stockIn(eq("TENANT-A"), eq("OP-1"), any())).thenReturn(
|
||||
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var result = service.palletize("OP-1", 77L, "WTR-1",
|
||||
new WarehouseTransferRequests.Palletize("DONE-1", List.of(9001L)));
|
||||
|
||||
assertEquals(WarehouseTransferService.WAIT_CUSTOMER_CONFIRMATION, result.status());
|
||||
assertEquals("COMPLETED", pallet.getStatus());
|
||||
assertEquals(WarehouseTransferService.INBOUND_COMPLETED, tail.getCurrentStatus());
|
||||
assertEquals(5, tail.getReceivedPackageCount());
|
||||
assertEquals("RECEIVED", inbound.getStatus());
|
||||
assertEquals(5, inbound.getPackageCount());
|
||||
assertEquals("COMPLETED", inboundPlan.getStatus());
|
||||
verify(transferPalletItems).insert(any(com.baiyue.lastleg.transfer.entity.WarehouseTransferPalletItem.class));
|
||||
verify(inventory).stockIn(eq("TENANT-A"), eq("OP-1"), any());
|
||||
verify(stockReceipts).insert(any(com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt.class));
|
||||
verify(inboundOrders).updateById(inbound);
|
||||
verify(inboundPlans).updateById(inboundPlan);
|
||||
verify(inboundPlanPallets).insert(any(com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet.class));
|
||||
ArgumentCaptor<com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation> allocation =
|
||||
ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation.class);
|
||||
verify(inboundPlanAllocations).insert(allocation.capture());
|
||||
assertEquals(5, allocation.getValue().getPickedPackageCount());
|
||||
verify(warehouseResources, never()).ensurePalletBatch(any());
|
||||
verify(warehouseResources, never()).confirmPalletBatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,7 +271,7 @@ class WarehouseTransferServiceTest {
|
||||
LastlegOrder historical = historicalOrder();
|
||||
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
|
||||
when(orders.selectList(any())).thenReturn(List.of(historical));
|
||||
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
|
||||
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
@@ -120,23 +280,23 @@ class WarehouseTransferServiceTest {
|
||||
ArgumentCaptor<LastlegOrder> tailOrder = ArgumentCaptor.forClass(LastlegOrder.class);
|
||||
verify(orders).insert(tailOrder.capture());
|
||||
assertEquals(WarehouseTransferService.ORDER_TYPE, tailOrder.getValue().getOrderType());
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
|
||||
assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
|
||||
assertEquals(WarehouseTransferService.DRAFT, tailOrder.getValue().getCurrentStatus());
|
||||
assertEquals("WAREHOUSE_TRANSFER:DRAFT;RELABEL:PENDING_BINDING", tailOrder.getValue().getSubOrderStatus());
|
||||
assertTrue(tailOrder.getValue().getOrderNo().matches("WT-WH-\\d{8}-\\d{6}"));
|
||||
assertNull(tailOrder.getValue().getTransferWarehouseId());
|
||||
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
|
||||
verify(relabelApplications, never()).insert(any(RelabelApplication.class));
|
||||
assertNull(tailOrder.getValue().getRelabelOrderNo());
|
||||
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus());
|
||||
assertEquals(WarehouseTransferService.DRAFT, result.status());
|
||||
assertEquals(WarehouseTransferService.DRAFT, result.orderStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferWithoutRelabelDoesNotCreateRelabelDocument() {
|
||||
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
|
||||
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
|
||||
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
|
||||
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
@@ -146,6 +306,27 @@ class WarehouseTransferServiceTest {
|
||||
assertNull(result.relabelNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferRelabelRejectsMultipleDestinationWarehouses() {
|
||||
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
|
||||
null, null, "WH-1", null, "NONE", null, null, null,
|
||||
null, null, null, null, "123 Pickup Road", null, null, 2,
|
||||
new BigDecimal("0.1000"), "CUSTOMER_DELIVERY", LocalDateTime.of(2026, 9, 18, 10, 0),
|
||||
null, null, null, "BOX-1,BOX-2", null, true,
|
||||
LocalDateTime.of(2026, 9, 19, 10, 0), "Contact", "13800000000", null, "TRUCK",
|
||||
"single-destination-check", List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 2, null)),
|
||||
List.of(
|
||||
new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-1"), "SKU-1", null, 1, "FBA-1", "ONT8", null),
|
||||
new WarehouseTransferRequests.RelabelItem("CARTON_LABEL", List.of("BOX-2"), "SKU-1", null, 1, "FBA-2", "ACY2", null)));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.create("TENANT-A", "USER-1", request));
|
||||
|
||||
assertEquals(400, exception.getStatusCode().value());
|
||||
assertEquals("Warehouse transfer relabel only supports one destination warehouse per order", exception.getReason());
|
||||
verifyNoInteractions(transfers);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferCanEnterTheTransitWarehouseBeforeFbaDestinationIsKnown() {
|
||||
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
|
||||
@@ -155,7 +336,7 @@ class WarehouseTransferServiceTest {
|
||||
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
|
||||
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
|
||||
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
|
||||
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer());
|
||||
when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
@@ -179,7 +360,7 @@ class WarehouseTransferServiceTest {
|
||||
null, "TRACK-MUST-NOT-BE-SAVED", null, "Incoming transit cargo", true, "key-incoming",
|
||||
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product A", 2, null),
|
||||
new WarehouseTransferRequests.Item("SKU-2", "Product B", 3, null)));
|
||||
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> persistedTransfer());
|
||||
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
@@ -199,7 +380,7 @@ class WarehouseTransferServiceTest {
|
||||
assertEquals(transfer.getValue().getTransferNo(), transfer.getValue().getInboundCode());
|
||||
assertEquals(transfer.getValue().getTransferNo(), inbound.getValue().getInboundCode());
|
||||
assertEquals("OVERSEAS", inbound.getValue().getDestinationType());
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getValue().getStatus());
|
||||
assertEquals(WarehouseTransferService.DRAFT, inbound.getValue().getStatus());
|
||||
assertNull(transfer.getValue().getTrackingNo());
|
||||
assertEquals("PENDING_BINDING", tailOrder.getValue().getRelabelStatus());
|
||||
assertEquals(List.of(1, 2, 3, 4, 5), cartons.getAllValues().stream()
|
||||
@@ -209,7 +390,7 @@ class WarehouseTransferServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedTransferWithoutRelabelEntersPendingAssignment() {
|
||||
void approvedTransferWithoutRelabelEntersInboundMeasuring() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
transfer.setNeedRelabel(false);
|
||||
@@ -225,14 +406,134 @@ class WarehouseTransferServiceTest {
|
||||
new WarehouseTransferRequests.Review("APPROVE", null));
|
||||
|
||||
assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus());
|
||||
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus());
|
||||
assertEquals(WarehouseTransferService.INBOUND_MEASURING, tailOrder.getCurrentStatus());
|
||||
assertEquals(WarehouseTransferService.APPROVED, result.status());
|
||||
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
|
||||
assertEquals(WarehouseTransferService.INBOUND_MEASURING, result.orderStatus());
|
||||
verify(transfers).updateById(transfer);
|
||||
verify(orders).updateById(tailOrder);
|
||||
verifyNoInteractions(inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvedIncomingTransferCreatesPendingInboundPlan() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
transfer.setInboundCode("WTR-1"); transfer.setOperatingWarehouseId("77");
|
||||
transfer.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 17, 10, 0));
|
||||
LastlegOrder tailOrder = tailOrder(); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
LastlegInboundOrder inbound = new LastlegInboundOrder();
|
||||
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(31L)).thenReturn(tailOrder);
|
||||
when(inboundOrders.selectById(101L)).thenReturn(inbound);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
service.review("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Review("APPROVE", null));
|
||||
|
||||
ArgumentCaptor<InboundPlan> plan = ArgumentCaptor.forClass(InboundPlan.class);
|
||||
ArgumentCaptor<InboundPlanLine> line = ArgumentCaptor.forClass(InboundPlanLine.class);
|
||||
verify(inboundPlans).insert(plan.capture());
|
||||
verify(inboundPlanLines).insert(line.capture());
|
||||
assertEquals("PDA_PENDING", plan.getValue().getStatus());
|
||||
assertEquals("WT-WTR-1", plan.getValue().getPlanNo());
|
||||
assertEquals("WTR-1", line.getValue().getInboundCode());
|
||||
assertEquals(5, line.getValue().getRequiredPackageCount());
|
||||
assertEquals("PENDING_INBOUND", inbound.getStatus());
|
||||
assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantSubmitsDraftForReviewBeforePrintingLabels() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
transfer.setStatus(WarehouseTransferService.DRAFT);
|
||||
transfer.setInboundCode("WTR-1");
|
||||
LastlegOrder tailOrder = historicalOrder();
|
||||
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setRelabelStatus("NOT_REQUIRED");
|
||||
LastlegInboundOrder inbound = new LastlegInboundOrder();
|
||||
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.DRAFT);
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(31L)).thenReturn(tailOrder);
|
||||
when(inboundOrders.selectById(101L)).thenReturn(inbound);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var result = service.submit("TENANT-A", "WTR-1", "USER-1");
|
||||
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, transfer.getStatus());
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus());
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, inbound.getStatus());
|
||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
|
||||
verify(transfers).updateById(transfer);
|
||||
verify(orders).updateById(tailOrder);
|
||||
verify(inboundOrders).updateById(inbound);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantUpdatesDraftAndRebuildsEditableSnapshots() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
transfer.setStatus(WarehouseTransferService.DRAFT);
|
||||
LastlegOrder tailOrder = historicalOrder();
|
||||
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus(WarehouseTransferService.DRAFT);
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(31L)).thenReturn(tailOrder);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
|
||||
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false));
|
||||
|
||||
assertEquals(WarehouseTransferService.DRAFT, result.status());
|
||||
assertEquals("WH-1", transfer.getOperatingWarehouseId());
|
||||
assertEquals(new BigDecimal("0.2500"), tailOrder.getVolumeCbm());
|
||||
verify(transfers).updateById(transfer);
|
||||
verify(orders).updateById(tailOrder);
|
||||
verify(transferCartons).delete(any());
|
||||
verify(transferItems).delete(any());
|
||||
verify(relabelBindings).delete(any());
|
||||
verify(orderDetails).delete(any());
|
||||
verify(transferItems).insert(any(WarehouseTransferItem.class));
|
||||
verify(transferCartons, times(5)).insert(any(WarehouseTransferCarton.class));
|
||||
verify(orderDetails).insert(any(LastlegOrderDetail.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantCannotUpdateSubmittedTransfer() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false)));
|
||||
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantDraftUpdateIsTenantScoped() {
|
||||
when(transfers.lockNo("TENANT-B", "WTR-1")).thenReturn(null);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.updateDraft("TENANT-B", "WTR-1", "USER-2", request(false)));
|
||||
|
||||
assertEquals(404, error.getStatusCode().value());
|
||||
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftCannotPrintCartonLabelsBeforeSubmission() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
transfer.setStatus(WarehouseTransferService.DRAFT);
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.cartonLabel("TENANT-A", "WTR-1"));
|
||||
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
verify(transferCartons, never()).listOrder(anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionRequiresNonBlankReasonBeforeAnyStateOrInventoryChange() {
|
||||
for (String decision : List.of("REJECT", "REJECTED")) {
|
||||
@@ -378,6 +679,67 @@ class WarehouseTransferServiceTest {
|
||||
assertEquals("RESERVED", allocation.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundRejectsTransferWithoutFbaDestinationBeforeInventoryMutation() {
|
||||
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
|
||||
transfer.setFbaWarehouseCode(null);
|
||||
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.outbound("TENANT-A", "WTR-1", "USER-1",
|
||||
new WarehouseTransferRequests.Outbound("OUT-NO-FBA", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verifyNoInteractions(inventory);
|
||||
verify(inventoryAllocations, never()).lockTransfer(anyString(), anyLong());
|
||||
verify(transfers, never()).updateById(any(WarehouseTransferOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantMeasurementPhotosExposeOnlySubmittedSelection() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
WarehouseTransferMeasurementPhoto selected = new WarehouseTransferMeasurementPhoto();
|
||||
selected.setFileId(101L); selected.setFileName("selected.jpg"); selected.setContentType("image/jpeg");
|
||||
selected.setSizeBytes(12L); selected.setUploadedAt(LocalDateTime.of(2026, 9, 16, 10, 0));
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(measurementPhotos.listSubmitted("TENANT-A", 21L)).thenReturn(List.of(selected));
|
||||
|
||||
var result = service.measurementPhotos("TENANT-A", "WTR-1");
|
||||
|
||||
assertEquals(List.of(101L), result.stream().map(value -> value.fileId()).toList());
|
||||
verify(measurementPhotos, never()).listOrder("TENANT-A", 21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantCannotDownloadUploadedButUnsubmittedMeasurementPhoto() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||
when(measurementPhotos.findSubmittedFile("TENANT-A", 21L, 102L)).thenReturn(null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.downloadMeasurementPhoto("TENANT-A", "WTR-1", 102L));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verifyNoInteractions(measurementPhotoFiles);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaCanDownloadOwnWarehouseUploadedPhotoBeforeSubmission() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
WarehouseTransferMeasurementPhoto draft = new WarehouseTransferMeasurementPhoto();
|
||||
draft.setFileId(102L);
|
||||
var downloaded = new TransferMeasurementPhotoFileGateway.DownloadedFile(
|
||||
new byte[]{1}, "draft.jpg", "image/jpeg");
|
||||
when(transfers.findNoByWarehouse("WTR-1", "92")).thenReturn(transfer);
|
||||
when(measurementPhotos.findFile("TENANT-A", 21L, 102L)).thenReturn(draft);
|
||||
when(measurementPhotoFiles.download("TENANT-A", 21L, 102L)).thenReturn(downloaded);
|
||||
|
||||
var result = service.downloadPdaMeasurementPhoto(92L, "WTR-1", 102L);
|
||||
|
||||
assertEquals("draft.jpg", result.fileName());
|
||||
verify(measurementPhotos, never()).findSubmittedFile(anyString(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allocationRejectsPositionOutsideSelectedInboundOrder() {
|
||||
WarehouseTransferOrder transfer = transfer("APPROVED");
|
||||
@@ -427,6 +789,7 @@ class WarehouseTransferServiceTest {
|
||||
transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L);
|
||||
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false);
|
||||
transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5);
|
||||
transfer.setFbaWarehouseCode("ONT8");
|
||||
return transfer;
|
||||
}
|
||||
|
||||
@@ -436,6 +799,24 @@ class WarehouseTransferServiceTest {
|
||||
return transfer;
|
||||
}
|
||||
|
||||
private static WarehouseTransferPallet measuredPallet(String status) {
|
||||
WarehouseTransferPallet pallet = new WarehouseTransferPallet();
|
||||
pallet.setId(61L); pallet.setTransferOrderId(21L); pallet.setCustomerId("TENANT-A");
|
||||
pallet.setSequenceNo(1); pallet.setWarehousePalletId(701L); pallet.setPalletNo("NEW-PALLET-1");
|
||||
pallet.setCartonCount(5); pallet.setLengthCm(new BigDecimal("100.00"));
|
||||
pallet.setWidthCm(new BigDecimal("100.00")); pallet.setHeightCm(new BigDecimal("100.00"));
|
||||
pallet.setVolumeCbm(new BigDecimal("1.000000")); pallet.setGrossWeightKg(new BigDecimal("50.000"));
|
||||
pallet.setStatus(status);
|
||||
return pallet;
|
||||
}
|
||||
|
||||
private static WarehouseTransferItem transferItem(long id, String sku, int packages) {
|
||||
WarehouseTransferItem item = new WarehouseTransferItem();
|
||||
item.setId(id); item.setTransferOrderId(21L); item.setCustomerId("TENANT-A");
|
||||
item.setSkuCode(sku); item.setProductNameZh("Product"); item.setPackageCount(packages);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static LastlegOrder tailOrder() {
|
||||
LastlegOrder order = historicalOrder();
|
||||
order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
|
||||
Reference in New Issue
Block a user