diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml index 9af5a8d4a..5acc5cbe2 100644 --- a/config/nacos/local/baiyue-common.yml +++ b/config/nacos/local/baiyue-common.yml @@ -40,6 +40,8 @@ spring: timeout: ${REDIS_TIMEOUT:3s} cloud: nacos.discovery: + # Local services must stay reachable through the gateway when VPN adapters change priority. + ip: ${NACOS_DISCOVERY_IP:127.0.0.1} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} openfeign: diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4249d4ce3..40a84bd1b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2189,7 +2189,7 @@ CREATE TABLE ll_relabel_application ( PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_application_no (customer_id,relabel_no), UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_order (lastleg_order_id), KEY idx_ll_relabel_application_list (customer_id,status,created_at), KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index ede78069f..5d7ef8417 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -72,6 +72,8 @@ public record LastlegOrderSummary( String customerRemark, String warehouseRemark, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + @Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序") + List relabelApplications ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/RelabelSubOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/RelabelSubOrder.java new file mode 100644 index 000000000..b9e4bac5e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/RelabelSubOrder.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; + +/** A relabel application keeps its own status within the received parent order. */ +public record RelabelSubOrder(Long lastlegOrderId, String relabelNo, String status, + Integer quantity, LocalDateTime createdAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 2fb5bc742..26dd83ad7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -16,6 +16,11 @@ import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { + @Select("") + List findRelabelSubOrders(@Param("orderIds") List orderIds); String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " + "OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java index 8e6052f4a..051a9659f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -38,7 +38,7 @@ public final class RelabelResponses { String destinationFbaWarehouseName, String skuCode, String productNameZh, long availablePackages, long version, List positions, String sourceType, Long returnCartonId, String cartonMark, - List skuItems) {} + List skuItems, long relabelledPackages) {} public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo, String relabelOrderNo, String orderType, String status, Integer packageCount, LocalDateTime createdAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 7869543ee..d7e0a524e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -260,7 +260,7 @@ public class RelabelApplicationService implements WalletResultHandler { balance.getOverseasWarehouseCode(), balance.getOverseasWarehouseName(), balance.getDestinationWarehouseCode(), balance.getDestinationWarehouseName(), balance.getSkuCode(), balance.getProductNameZh(), available, value(balance.getVersion()), positions, - "INBOUND", null, null, List.of()); + "INBOUND", null, null, List.of(), 0); }).filter(candidate -> candidate.availablePackages() > 0).toList()); for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { String keyword = trim(inboundCode); @@ -269,19 +269,23 @@ public class RelabelApplicationService implements WalletResultHandler { && !containsIgnoreCase(carton.getSourceReturnNo(), keyword)) continue; List 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(); + .map(item -> { + long used = returnCartons.relabelledQuantity(carton.getId(), item.getSkuCode()); + long remaining = value(item.getQuantity()) - used; + return new RelabelResponses.SkuItem(item.getSkuCode(), null, + Math.toIntExact(Math.max(remaining, 0)), null); + }).filter(item -> item.quantity() > 0).toList(); if (cartonSkus.isEmpty()) continue; String skuCodes = cartonSkus.stream().map(RelabelResponses.SkuItem::skuCode) .collect(Collectors.joining(",")); - long availablePackages = value(carton.getTotalQuantity()); - if (availablePackages <= 0) { - availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); - } + long availablePackages = cartonSkus.stream().mapToLong(RelabelResponses.SkuItem::quantity).sum(); + if (availablePackages <= 0) continue; + long relabelledPackages = carton.getTotalQuantity() == null ? 0 + : Math.max(0, carton.getTotalQuantity() - availablePackages); result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(), null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(), - "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus)); + "RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus, relabelledPackages)); } return result; } @@ -380,9 +384,10 @@ public class RelabelApplicationService implements WalletResultHandler { if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); } + long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode()); long requestedPackages = selectedReturnPackages.merge(carton.getId(), (long) item.notifiedPackageCount(), Math::addExact); - if (requestedPackages > value(carton.getTotalQuantity())) { + if (requestedPackages + previouslyRelabelled > value(carton.getTotalQuantity())) { throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足"); } sources.add(new SourceInventory(item, null, carton)); @@ -455,9 +460,9 @@ public class RelabelApplicationService implements WalletResultHandler { applications.insert(application); for (ReturnInventoryCarton carton : selectedReturnCartons.values()) { - carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo); - carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); - returnCartons.updateById(carton); + if (returnCartons.markRelabelPending(carton.getId(), applicationId, relabelNo, now) != 1) { + throw conflict("退货箱状态已变化,请刷新后重试"); + } } if (requiresWallet) { @@ -600,6 +605,7 @@ public class RelabelApplicationService implements WalletResultHandler { } } for (LastlegOrder order : linkedOrders) { + if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; String orderBefore = order.getCurrentStatus(); String mainStatus = "RELABEL_APPLICATION".equals(order.getOrderType()) ? "WAIT_RELABEL" : orderBefore; @@ -612,8 +618,7 @@ public class RelabelApplicationService implements WalletResultHandler { }; // Reviewing/processing a return relabel must not move the already // completed inbound order back to assignment or relabel waiting. - if ("RETURN_RELABEL".equals(order.getOrderType()) - && !Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(next)) { + if ("RETURN_RELABEL".equals(order.getOrderType())) { orderStatus = "INBOUND_COMPLETED"; } order.setCurrentStatus(orderStatus); @@ -703,6 +708,7 @@ public class RelabelApplicationService implements WalletResultHandler { application.setStatus("PROCESSING"); application.setFulfillmentStatus("WAIT_RELABEL"); application.setUpdatedAt(LocalDateTime.now()); applications.updateById(application); for (LastlegOrder order : tailOrders(application)) { + if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; order.setRelabelStatus("PROCESSING"); order.setSubOrderStatus("RELABEL:PROCESSING"); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order); } @@ -748,9 +754,18 @@ public class RelabelApplicationService implements WalletResultHandler { } application.setUpdatedAt(now); applications.updateById(application); + WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null + : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); for (LastlegOrder order : tailOrders(application)) { + if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; + // 仓储转运尾程单由下方按转运目的仓一次性写入精确状态,避免先落入通用状态。 + if (linkedTransfer != null && "WAIT_RELABEL".equals(linkedTransfer.getStatus()) + && Objects.equals(order.getId(), linkedTransfer.getLastlegOrderId())) continue; String orderBefore = order.getCurrentStatus(); - order.setCurrentStatus(requiresOutbound ? "PENDING_ASSIGNMENT" : "COMPLETED"); + // 换标确认时货物已经完成入库,不得退回最初建单的“待分配”。 + // 独立换标先进入待出库;仓储转运会在下方按目的仓进一步收敛为待排车或待确认目的仓。 + order.setCurrentStatus("RETURN_RELABEL".equals(order.getOrderType()) ? "INBOUND_COMPLETED" + : requiresOutbound ? "WAIT_OUTBOUND" : "COMPLETED"); order.setSubOrderStatus("RELABEL:" + application.getFulfillmentStatus()); order.setRelabelStatus("COMPLETED"); order.setUpdatedAt(now); @@ -774,13 +789,16 @@ public class RelabelApplicationService implements WalletResultHandler { ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); if (carton == null || !Objects.equals(carton.getRelabelApplicationId(), application.getId())) continue; carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now); + // 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。 + // 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。 + carton.setRelabelApplicationId(null); carton.setRelabelNo(null); carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); } insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); // 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。 - if (warehouseTransfers != null) { - WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + if (linkedTransfer != null) { + WarehouseTransferOrder transfer = linkedTransfer; if (transfer != null && "WAIT_RELABEL".equals(transfer.getStatus())) { Set destinations = relabelItems.stream() .map(RelabelItem::getDestinationFbaWarehouseCode) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java index 8011f4288..c934a1eaa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java @@ -8,7 +8,8 @@ public final class ReturnOrderResponses { public record Summary(String returnNo, String lastlegOrderNo, String overseasWarehouseId, String warehouseContactName, String warehouseContactPhone, String customerReferenceNo, String status, int skuTypes, long expectedQuantity, long receivedQuantity, boolean needRelabel, - String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt) { } + String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt, + List relabelApplications) { } public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { } public record Detail(Summary order, String remark, List items, List receipts, List pallets, List photos) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java index ac0540219..19b529321 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java @@ -36,6 +36,12 @@ public interface ReturnInventoryCartonMapper extends BaseMapper0 AND total_quantity>0 AND relabel_application_id IS NULL") + int markRelabelPending(@Param("id") long id, @Param("applicationId") long applicationId, + @Param("relabelNo") String relabelNo, @Param("updatedAt") java.time.LocalDateTime updatedAt); + @Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1") ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java index 3eb209b27..ea900cb9e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java @@ -13,6 +13,8 @@ public interface ReturnOrderMapper extends BaseMapper { ReturnOrder findKey(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1") ReturnOrder findNo(@Param("customerId") String customerId, @Param("returnNo") String returnNo); + @Select("SELECT * FROM ll_return_order WHERE customer_id=#{customerId} AND return_no=#{returnNo} LIMIT 1 FOR UPDATE") + ReturnOrder lockCustomerNo(@Param("customerId") String customerId, @Param("returnNo") String returnNo); @Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} LIMIT 1 FOR UPDATE") ReturnOrder lockNo(@Param("returnNo") String returnNo); @Select("SELECT * FROM ll_return_order WHERE return_no=#{returnNo} AND overseas_warehouse_id=#{warehouseId} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 609534dc7..858536260 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -186,7 +186,9 @@ public class ReturnReceiptService { w.in(ReturnOrder::getId, ids); } Page result = orders.selectPage(Page.of(query.page(), query.size()), w); - return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), + var relabels = relabelSubOrders(result.getRecords()); + return new PageResult<>(result.getRecords().stream().map(order -> summary(order, + relabels.getOrDefault(order.getLastlegOrderId(), List.of()))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } @@ -216,7 +218,7 @@ public class ReturnReceiptService { }).toList(); List photos = photoList(order).stream() .map(file -> new ReturnOrderResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList(); - return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews, palletViews, photos); + return new ReturnOrderResponses.Detail(summary(order, relabelSubOrders(List.of(order)).getOrDefault(order.getLastlegOrderId(), List.of())), order.getRemark(), items, receiptViews, palletViews, photos); } public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) { @@ -265,7 +267,7 @@ public class ReturnReceiptService { @Transactional public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo, ReturnOrderRequests.RelabelCreate request) { - ReturnOrder order = orders.findNo(customerId, text(returnNo)); + ReturnOrder order = orders.lockCustomerNo(customerId, text(returnNo)); if (order == null) throw notFound("退货单不存在"); if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收"); String fileScope = norm(request.fileScope()); @@ -273,9 +275,14 @@ public class ReturnReceiptService { String key = text(request.idempotencyKey()); String requestHash = hash(request); RelabelApplication replay = relabelApplications.findKey(customerId, key); - if (replay != null) return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), - order.getReturnNo(), request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), replay.getStatus()); - if (text(order.getRelabelNo()) != null) throw conflict("当前退货单已经生成贴标申请"); + if (replay != null) { + if (!order.getReturnNo().equals(replay.getCustomerOrderNo()) || !requestHash.equals(replay.getRequestHash())) + throw conflict("相同幂等键的贴标申请内容不一致"); + return new ReturnOrderResponses.RelabelCreated(replay.getRelabelNo(), replay.getId(), + order.getReturnNo(), request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), replay.getStatus()); + } + // The order keeps the latest application reference, not an order-wide inventory lock. + // Each new application must still allocate only unlabelled, available carton quantities below. var destination = fbaWarehouseValidator.required(request.destinationFbaWarehouseId(), request.destinationFbaWarehouseCode(), null); Map receivedBySku = orderItems.lockOrder(order.getId()).stream() .collect(java.util.stream.Collectors.toMap(item -> norm(item.getSkuCode()), item -> item)); @@ -311,8 +318,10 @@ public class ReturnReceiptService { tail.setRelabelStatus("PENDING_REVIEW"); tail.setSubOrderStatus("RETURN_RELABEL:PENDING_REVIEW"); tail.setCurrentStatus("RETURN_RELABEL".equals(tail.getOrderType()) ? "INBOUND_COMPLETED" : tail.getCurrentStatus()); - tail.setPackageCount(Math.toIntExact(totalQuantity)); - tail.setProductQuantity(Math.toIntExact(totalQuantity)); + if (!"RETURN_RELABEL".equals(tail.getOrderType())) { + tail.setPackageCount(Math.toIntExact(totalQuantity)); + tail.setProductQuantity(Math.toIntExact(totalQuantity)); + } tail.setUpdatedAt(now); lastlegOrders.updateById(tail); RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); @@ -331,6 +340,7 @@ public class ReturnReceiptService { long skuQuantity = cartonItems.listCarton(candidate.getId()).stream() .filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode()))) .mapToLong(line -> value(line.getQuantity())).sum(); + skuQuantity -= cartons.relabelledQuantity(candidate.getId(), norm(requested.skuCode())); if (skuQuantity <= 0) continue; ReturnInventoryCarton carton = selectedCartons.computeIfAbsent(candidate.getId(), ignored -> cartons.lockId(candidate.getId())); if (carton == null || !"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) @@ -551,10 +561,16 @@ public class ReturnReceiptService { return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), order.getStatus(), completed, value(order.getExpectedQuantity()), value(order.getReceivedQuantity()), receipt.getSignedAt()); } - private ReturnOrderResponses.Summary summary(ReturnOrder order) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(), + private Map> relabelSubOrders(List values) { + var ids = values.stream().map(ReturnOrder::getLastlegOrderId).filter(java.util.Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) return java.util.Collections.emptyMap(); + var rows = lastlegOrders.findRelabelSubOrders(ids); + return rows == null ? java.util.Collections.emptyMap() : rows.stream().collect(java.util.stream.Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId)); + } + private ReturnOrderResponses.Summary summary(ReturnOrder order, List relabelApplications) { return new ReturnOrderResponses.Summary(order.getReturnNo(), order.getLastlegOrderNo(), order.getOverseasWarehouseId(), order.getWarehouseContactName(), order.getWarehouseContactPhone(), order.getCustomerReferenceNo(), order.getStatus(), order.getExpectedSkuTypes() == null ? 0 : order.getExpectedSkuTypes(), value(order.getExpectedQuantity()), - value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt()); } + value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt(), relabelApplications); } private ReturnOrderResponses.Item item(ReturnOrderItem item) { return new ReturnOrderResponses.Item(item.getSkuCode(), item.getProductNameZh(), value(item.getExpectedQuantity()), value(item.getReceivedQuantity())); } private PdaReturnReceiptResponses.Item pdaItem(ReturnOrderItem item) { return new PdaReturnReceiptResponses.Item(item.getSkuCode(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 49812a1c3..214ec7563 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -96,10 +96,12 @@ public class LastlegOrderService { Map containerNodes = containerNodes(page.getRecords()); Map dispatchStatuses = dispatchStatuses(page.getRecords()); Map weights = orderWeights(page.getRecords()); + Map> relabels = relabelSubOrders(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null, - dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()))).toList(), + dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()), + relabels.getOrDefault(order.getId(), List.of()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -353,7 +355,7 @@ public class LastlegOrderService { .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), - orderWeights(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() + orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -577,7 +579,10 @@ public class LastlegOrderService { .like(LastlegOrder::getOrderNo, query.orderNo()) .or() .like(LastlegOrder::getSourceOrderNo, query.orderNo())) - .like(StringUtils.hasText(relabelNo), LastlegOrder::getRelabelOrderNo, relabelNo) + .and(StringUtils.hasText(relabelNo), nested -> nested + .like(LastlegOrder::getRelabelOrderNo, relabelNo) + .or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " + + "AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo)) .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) @@ -637,9 +642,16 @@ public class LastlegOrderService { return rows.stream().collect(Collectors.toMap(LastlegOrderMapper.OrderWeightRow::orderId, row -> row)); } + private Map> relabelSubOrders(List orders) { + if (orders.isEmpty()) return Map.of(); + var rows = orderMapper.findRelabelSubOrders(orders.stream().map(LastlegOrder::getId).toList()); + return rows == null ? Map.of() : rows.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dto.RelabelSubOrder::lastlegOrderId)); + } + private LastlegOrderSummary summary(LastlegOrder o, List relations, String containerNode, String dispatchStatus, - LastlegOrderMapper.OrderWeightRow weight) { + LastlegOrderMapper.OrderWeightRow weight, + List relabelApplications) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), @@ -657,7 +669,7 @@ public class LastlegOrderService { o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), - o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications); } private static Integer packageDifference(LastlegOrder order) { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index f6f240715..16a0622b0 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1826,7 +1826,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application ( PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_application_no (customer_id,relabel_no), UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), - UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_order (lastleg_order_id), KEY idx_ll_relabel_application_list (customer_id,status,created_at), KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 0af7a9337..ee70f6513 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -228,7 +228,7 @@ paths: - { name: detained, in: query, schema: { type: boolean } } - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } - responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;每行 relabelApplications 数组按创建时间返回所有换标子单的 relabelNo、status、quantity、createdAt,旧标量字段只代表最近一张;列表行 destinationType 为 FBA 或 OVERSEAS;inboundCode 仅返回真实来源入仓码,无入仓关系的换标申请和本土散板为空 } } /api/platform/v1/last-leg/orders/{id}: get: summary: 查询尾程订单详情 @@ -238,7 +238,7 @@ paths: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } responses: - '200': { description: 统一 ApiResponse 包装的尾程订单详情 } + '200': { description: 统一 ApiResponse 包装的尾程订单详情;order.relabelApplications 返回所有换标子单的独立状态和本次数量 } '404': { description: 尾程订单不存在或不属于查询客户 } /api/platform/v1/last-leg/orders/{id}/fees: post: @@ -1524,7 +1524,7 @@ paths: - { name: trackingNo, in: query, schema: { type: string } } - { name: skuCode, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string, enum: [PENDING_RECEIPT, PARTIALLY_RECEIVED, COMPLETED] } } - responses: { '200': { description: 当前客户退货订单分页 } } + responses: { '200': { description: 当前客户退货订单分页;每行 relabelApplications 返回所有换标子单的 relabelNo、status、quantity、createdAt,主单 status 仍表示退货签收状态 } } post: summary: 客户创建退货预报订单 description: 权限 merchant:overseas-warehouse:return-order:create;客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 @@ -2691,6 +2691,15 @@ components: batchNo: { type: string, nullable: true } warehouseLocationCode: { type: string, nullable: true } availablePackages: { type: integer, format: int64 } + RelabelSubOrder: + type: object + description: 同一主单可分批生成多张换标申请;各自保留独立状态,取消或完成子单不会覆盖主单签收状态。 + properties: + lastlegOrderId: { type: string } + relabelNo: { type: string } + status: { type: string } + quantity: { type: integer, description: 本次申请数量 } + createdAt: { type: string, format: date-time } RelabelHistoricalOrder: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java index 6c594354e..bf90f8c2f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -44,6 +44,12 @@ class RelabelContractTest { 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 schema : new String[] { baseline, deployment }) { + assertTrue(schema.contains("key idx_ll_relabel_application_order (lastleg_order_id)")); + assertFalse(schema.contains("unique key uk_ll_relabel_application_order"), + "One received return order must support multiple relabel applications for remaining stock"); + assertTrue(schema.contains("unique key uk_ll_relabel_application_idempotency")); + } for (String schema : new String[] { baseline, migration }) { assertTrue(schema.contains("ll_relabel_application")); assertTrue(schema.contains("ll_relabel_item")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index ec07e9bf0..6e5db795d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -466,12 +466,13 @@ class RelabelApplicationServiceTest { } @Test - void completedReturnRelabelOrderEntersPendingAssignment() { + void completedReturnRelabelKeepsParentInboundCompleted() { RelabelApplication application = application(11L, "FBR-1", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); application.setLastlegOrderId(21L); application.setFulfillmentStatus("PRE_DISPATCH_CREATED"); LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "PROCESSING"); order.setOrderType("RETURN_RELABEL"); + order.setRelabelOrderNo("FBR-1"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); when(orders.selectById(21L)).thenReturn(order); @@ -481,10 +482,32 @@ class RelabelApplicationServiceTest { assertEquals("COMPLETED", application.getStatus()); assertEquals("OUTBOUNDED", application.getFulfillmentStatus()); - assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus()); + assertEquals("INBOUND_COMPLETED", order.getCurrentStatus()); assertEquals("COMPLETED", order.getRelabelStatus()); } + @Test + void completingEarlierReturnRelabelDoesNotOverwriteLaterApplicationStatus() { + RelabelApplication application = application(11L, "FBR-FIRST", "10001", "key-1", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); + application.setLastlegOrderId(21L); + application.setFulfillmentStatus("PRE_DISPATCH_CREATED"); + LastlegOrder order = order(21L, "10001", "LMO-RELABEL-1", "INBOUND_COMPLETED"); + order.setOrderType("RETURN_RELABEL"); + order.setRelabelOrderNo("FBR-SECOND"); + order.setRelabelStatus("PROCESSING"); + when(applications.lockNo("10001", "FBR-FIRST")).thenReturn(application); + stubDetail(application); + when(orders.selectById(21L)).thenReturn(order); + + service.changeStatus("10001", "FBR-FIRST", "operator-2", + new RelabelRequests.ChangeStatus("COMPLETED", "finished")); + + assertEquals("COMPLETED", application.getStatus()); + assertEquals("INBOUND_COMPLETED", order.getCurrentStatus()); + assertEquals("PROCESSING", order.getRelabelStatus()); + assertEquals("FBR-SECOND", order.getRelabelOrderNo()); + } + @Test void completingPartialRelabelReleasesOnlyNotifiedPackages() { RelabelApplication application = application(11L, "FBR-PARTIAL", "10001", "key-partial", "hash", RelabelApplicationService.READY_FOR_OUTBOUND); @@ -508,8 +531,10 @@ class RelabelApplicationServiceTest { @Test void truckCustomerConfirmationIsIdempotentAndWaitsForPreDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); + LastlegOrder linked = order(21L, "10001", "LMO-RELABEL-1", "WAIT_RELABEL"); when(applications.lockNo("10001", "FBR-1")).thenReturn(application); stubDetail(application); + when(orders.selectById(21L)).thenReturn(linked); service.confirmCompletion("10001", "FBR-1", "customer-1", new RelabelRequests.CustomerConfirm("confirm-1", "photos accepted")); @@ -520,6 +545,7 @@ class RelabelApplicationServiceTest { verify(pdaTasks).updateWithVersion(review.capture(), eq(1)); assertEquals("COMPLETED", review.getValue().getStatus()); assertEquals("CONFIRMED", review.getValue().getReviewDecision()); + assertEquals("WAIT_OUTBOUND", linked.getCurrentStatus()); assertNull(application.getOutboundOrderNo()); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); @@ -555,6 +581,7 @@ class RelabelApplicationServiceTest { @Test void transferRelabelConfirmationReleasesTransferForDispatch() { RelabelApplication application = confirmationApplication("TRUCK", false); + application.setLastlegOrderId(88L); WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(77L); transfer.setCustomerId("10001"); transfer.setRelabelNo("FBR-1"); transfer.setStatus("WAIT_RELABEL"); transfer.setLastlegOrderId(88L); transfer.setFbaWarehouseCode("ONT8"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java index 163342279..de7433c01 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java @@ -38,6 +38,7 @@ class RelabelReturnCartonCandidateTest { cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L); when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton)); when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem)); + when(cartons.relabelledQuantity(9001L, "SKU-RETURN")).thenReturn(1L); List result = service.candidates("10001", "WH-1", "RC-WH"); @@ -45,8 +46,8 @@ class RelabelReturnCartonCandidateTest { assertEquals("RETURN_CARTON", result.get(0).sourceType()); assertEquals(9001L, result.get(0).returnCartonId()); assertEquals("RC-WH-0001", result.get(0).cartonMark()); - assertEquals(12, result.get(0).availablePackages()); + assertEquals(11, result.get(0).availablePackages()); assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode()); - assertEquals(12, result.get(0).skuItems().get(0).quantity()); + assertEquals(11, result.get(0).skuItems().get(0).quantity()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 2bc877ac7..7e9d1415e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -93,6 +93,22 @@ class ReturnReceiptServiceTest { identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L); } + @Test + void detailKeepsEveryRelabelApplicationAndItsOwnStatus() { + ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90); + order.setLastlegOrderId(99L); + when(orders.findNo("customer-1", "RT-MULTI")).thenReturn(order); + var completed = new com.baiyue.lastleg.dto.RelabelSubOrder(99L, "RL-FIRST", "COMPLETED", 10, null); + var processing = new com.baiyue.lastleg.dto.RelabelSubOrder(99L, "RL-SECOND", "PROCESSING", 5, null); + when(lastlegOrders.findRelabelSubOrders(List.of(99L))).thenReturn(List.of(completed, processing)); + + var result = service.detail("customer-1", "RT-MULTI").order(); + + assertEquals("COMPLETED", result.status()); + assertEquals(List.of(completed, processing), result.relabelApplications()); + assertEquals(90L, result.receivedQuantity()); + } + @Test void merchantPhotoDownloadUsesAuthenticatedCustomerAndResolvedReturnOrder() { ReflectionTestUtils.setField(service, "photoFiles", photoFiles); @@ -128,12 +144,26 @@ class ReturnReceiptServiceTest { } private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity) { + verifyMultiSkuRelabel(firstQuantity, secondQuantity, false); + } + + @Test + void previouslyRelabelledReturnCanLabelItsRemainingAvailableGoods() { + verifyMultiSkuRelabel(10, 5, true); + } + + private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity, boolean previousRelabel) { ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90); + if (previousRelabel) { + order.setRelabelApplicationId(88L); + order.setRelabelNo("RL-PREVIOUS"); + order.setRelabelStatus("COMPLETED"); + } order.setLastlegOrderId(99L); LastlegOrder tail = new LastlegOrder(); tail.setId(99L); ReturnInventoryCarton first = returnCarton(101L, "RC-A", "P-1", 50); ReturnInventoryCarton second = returnCarton(102L, "RC-B", "P-2", 40); - when(orders.findNo("customer-1", "RT-MULTI")).thenReturn(order); + when(orders.lockCustomerNo("customer-1", "RT-MULTI")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40))); when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); @@ -152,6 +182,7 @@ class ReturnReceiptServiceTest { "SKU_TOTAL", "200", "XLX6", "multi-key", "multi SKU acceptance")); assertEquals("RL-MULTI", result.relabelNo()); + assertEquals("RL-MULTI", order.getRelabelNo()); var application = ArgumentCaptor.forClass(RelabelApplication.class); verify(relabelApplications).insert(application.capture()); assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue()); @@ -174,6 +205,45 @@ class ReturnReceiptServiceTest { return carton; } + @Test + void secondRelabelCannotAllocateAlreadyLabelledQuantity() { + ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100); + order.setLastlegOrderId(99L); order.setRelabelNo("RL-PREVIOUS"); + LastlegOrder tail = new LastlegOrder(); tail.setId(99L); + ReturnInventoryCarton carton = returnCarton(101L, "RC-A", "P-1", 100); + when(orders.lockCustomerNo("customer-1", "RT-MULTI")).thenReturn(order); + when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100))); + when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( + new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); + when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-SECOND"); + when(lastlegOrders.selectById(99L)).thenReturn(tail); + when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton)); + when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100))); + when(cartons.relabelledQuantity(101L, "SKU-A")).thenReturn(100L); + var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel( + "customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate( + List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)), + "SKU_TOTAL", "200", "XLX6", "second-key", "second attempt"))); + assertEquals(409, error.getStatusCode().value()); + assertTrue(error.getReason().contains("可用实收数量不足")); + verify(relabelItems, never()).insert(any(RelabelItem.class)); + verify(cartons, never()).updateById(any(ReturnInventoryCarton.class)); + } + + @Test + void relabelReplayRejectsKeyFromAnotherReturnOrder() { + ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100); + when(orders.lockCustomerNo("customer-1", "RT-MULTI")).thenReturn(order); + RelabelApplication old = new RelabelApplication(); old.setCustomerOrderNo("RT-OTHER"); + when(relabelApplications.findKey("customer-1", "used-key")).thenReturn(old); + var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel( + "customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate( + List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)), + "SKU_TOTAL", "200", "XLX6", "used-key", null))); + assertEquals(409, error.getStatusCode().value()); + verifyNoInteractions(cartons, cartonItems, relabelItems, fbaWarehouseValidator); + } + private static ReturnInventoryCartonItem returnCartonItem(String sku, long quantity) { ReturnInventoryCartonItem line = new ReturnInventoryCartonItem(); line.setSkuCode(sku); line.setQuantity(quantity); return line; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 3ff5b751c..4e8b1963c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -95,7 +95,7 @@ class LastlegOrderServiceTest { LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( service, "summary", order, List.of(), null, "UNSCHEDULED", - new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22"))); + new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of()); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); diff --git a/scripts/README.md b/scripts/README.md index 2b88625e8..ad44819c0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,10 +1,20 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/local-return-relabel-multiple-applications.sql`:本地 `baiyue_lastleg.ll_relabel_application` 单次结构修复,将尾程订单关联的唯一索引替换为普通索引,允许同一退货单剩余货物分批换标;不修改业务行,单号与幂等键唯一索引保留。凭据及目标连接由操作者显式提供,只在已授权本地库执行;DDL失败停止,已执行后不要重复运行,回查SHOW INDEX。新库结构同步在Lastleg基线和海外区域SQL中。 + +`local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。 + +`local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。 + +`db/local-dev-iam-number-sequence.sql`:经明确授权在 local/dev 的 `baiyue_iam` 补齐客户编号序列表,与 IAM 表结构基线一致;连接凭据由外部传入,无固定主机。创建表并初始化 `CUSTOMER_CODE`,事务锁定序列后按已有五位数字客户编码确定下限,已有较大序列不回退,不更新客户业务行。执行后输出表结构及序列,重复执行安全;失败停止,不清库,不用于区域空库初始化或生产迁移。 + +`local-return-relabel-acceptance.mjs`:仅本机网关的单票退货换标独立编排器。显式传入 `ADMIN_TEST_GATEWAY`、`ADMIN_OVERSEAS_GATEWAY`、平台账号密码、`OVERSEAS_TEST_CUSTOMER_ID` 和 `INTERNAL_SERVICE_TOKEN`,可指定 `OVERSEAS_TEST_WAREHOUSE_ID`;先核对本地服务数据库连接。复用已有启用客户,经正式代登录接口,新建唯一SKU和一件退货,审核、PDA签收、Print生成验收PDF、转换标、组托、合成照片完工、客户确认及实际换标库存出库。复用内部模块的步骤函数,不借用历史业务库存,不运行客户建档或全量边界。会创建并最终停用临时PDA,业务记录保留;报告由 `--output` 指定,默认 `reports/local-return-relabel-single-.json`,阶段检查点保存单号,失败停止且重跑前必须核对报告,不自动清理或自动续跑,不代表现场作业或页面验收。 + `db/test-return-relabel-checks.sql`:测试库退货转换标检查约束定向修复,显式目标 `baiyue_lastleg.ll_return_inventory_carton`。与 Lastleg 基线保持一致,允许正数量的 `RELABEL_PENDING`,保留可用/已出库正数量与已合箱零数量限制。一条原子 ALTER 同时替换两个既有 CHECK,不修改业务行、不删除数据、不关闭约束;重复执行得到相同约束,旧约束不存在则报错停止。凭据由外部测试数据库连接提供,执行前需测试库操作授权;末尾只读输出约束定义供回查。不是生产迁移入口,不能替代完整 HTTP 换标回归。 `../deploy/scripts/deploy-blue-green-service.sh`:受控部署流程调用的单服务蓝绿发布与健康检查入口;只由部署/Jenkins 编排使用,不作为普通验收脚本直接运行。目标服务、环境、镜像和凭据均由外部参数提供,失败按部署脚本回滚规则处理。 -`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 +`db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;修正未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单,并将已客户确认且换标完成、仍停在待换标/待分配的仓储转运单按目的仓恢复为待排车或待确认目的仓,同步尾程主单和换标履约状态;不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 `db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index de2e51619..df080bd58 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -375,6 +375,7 @@ export function parseArguments(argv) { verifyPalletLocationGuide: values.has('verify-pallet-location-guide'), routeMode: values.get('route-mode') || 'fixed', quiet: values.get('quiet') === 'true', + returnMainChainOnly: values.get('return-main-chain-only') === 'true', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', manualSplitPlanId: values.get('manual-split-plan-id') || '', manualPreloadSourceReport: values.get('manual-preload-source-report') || '', diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 113741e2d..2e157969d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +单票串行验收可显式传 `--modules phase4-overseas-return-followups --return-main-chain-only true`:仍由总入口准备本轮客户并完成退货签收、合箱、换标和出库;不执行第二客户、跨仓和并发扩展,报告 `excludedScope` 记录排除范围,不代表完整退货边界回归。目标由显式网关与账号参数指定;仅在已授权环境执行,保留业务单据及 `--output` 报告,失败先核对报告再重跑,不自动清理。 + 普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。 尾程暂扣按真实入库阶段验收:`phase4-tail-order-controls` 在 PDA 入库前验证暂扣返回冲突并回查订单零漂移,继续人工节点及整柜计划转换;`phase4-overseas-pda-operations` 接收本轮完成库存后调用同模块的 `verifyReceivedOrderHold`,重新核对本轮客户、订单和 `INBOUND_COMPLETED` 状态,再执行暂扣、同键重放/异参冲突、解除。正向暂扣未执行或前置不足仍是失败,不以入库前拒绝代替正向通过。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 5ff371f88..4507940a6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -479,7 +479,11 @@ export async function run(context) { trace(merchantSummary.response), OPTIONS) } - await runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) + if (context.options.returnMainChainOnly) { + context.createdResources.phase4OverseasReturnReceipt.excludedScope = 'Additional customer, cross-warehouse and concurrent return boundary scenarios'; + } else { + await runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) + } } finally { await disablePicker(context, picker, 91) } diff --git a/scripts/db/local-dev-iam-number-sequence.sql b/scripts/db/local-dev-iam-number-sequence.sql new file mode 100644 index 000000000..191700179 --- /dev/null +++ b/scripts/db/local-dev-iam-number-sequence.sql @@ -0,0 +1,21 @@ +-- Target only an explicitly authorized local/dev connection and baiyue_iam. +-- Preserve existing customer rows and never reset an existing sequence backwards. +USE baiyue_iam; +CREATE TABLE IF NOT EXISTS iam_number_sequence ( + sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型', + next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (sequence_key) +) ENGINE=InnoDB COMMENT='IAM业务编号序列表'; +START TRANSACTION; +INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1); +SELECT next_value FROM iam_number_sequence WHERE sequence_key='CUSTOMER_CODE' FOR UPDATE; +SET @customer_code_floor = ( + SELECT COALESCE(MAX(CAST(tenant_code AS UNSIGNED)), 0) + 1 + FROM iam_tenant WHERE tenant_code REGEXP '^[0-9]{5}$' +); +UPDATE iam_number_sequence SET next_value=GREATEST(next_value,@customer_code_floor) +WHERE sequence_key='CUSTOMER_CODE'; +COMMIT; +SHOW CREATE TABLE iam_number_sequence; +SELECT sequence_key,next_value FROM iam_number_sequence; diff --git a/scripts/db/local-return-relabel-multiple-applications.sql b/scripts/db/local-return-relabel-multiple-applications.sql new file mode 100644 index 000000000..9f8d735f4 --- /dev/null +++ b/scripts/db/local-return-relabel-multiple-applications.sql @@ -0,0 +1,19 @@ +-- Local baiyue_lastleg only; retain every application and all idempotency constraints. +USE baiyue_lastleg; +SET @relabel_has_unique = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'uk_ll_relabel_application_order'); +SET @relabel_has_index = (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' + AND index_name = 'idx_ll_relabel_application_order'); +SET @relabel_index_sql = CASE + WHEN @relabel_has_unique > 0 AND @relabel_has_index = 0 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order, ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)' + WHEN @relabel_has_unique > 0 THEN + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order' + WHEN @relabel_has_index = 0 THEN + 'ALTER TABLE ll_relabel_application ADD INDEX idx_ll_relabel_application_order (lastleg_order_id)' + ELSE 'SELECT 1' END; +PREPARE relabel_index_migration FROM @relabel_index_sql; +EXECUTE relabel_index_migration; +DEALLOCATE PREPARE relabel_index_migration; diff --git a/scripts/db/relabel-wait-status-incremental.sql b/scripts/db/relabel-wait-status-incremental.sql index d14947c6e..72b0c412c 100644 --- a/scripts/db/relabel-wait-status-incremental.sql +++ b/scripts/db/relabel-wait-status-incremental.sql @@ -13,4 +13,41 @@ WHERE o.order_type='RELABEL_APPLICATION' AND o.current_status='PENDING_ASSIGNMEN AND o.inbound_order_no IS NULL AND (a.status IN ('PENDING_PROCESSING','PROCESSING','CUSTOMER_CONFIRM_PENDING') OR (a.status='READY_FOR_OUTBOUND' AND a.fulfillment_status IN ('WAIT_PRE_DISPATCH','WAIT_OUTBOUND_CREATION','OUTBOUND_CREATED'))); + +-- 仓储转运已完成入库和换标后不得回到“待分配”;按是否已有目的仓恢复后续状态。 +UPDATE ll_warehouse_transfer_order t +JOIN ll_relabel_application a ON a.id=t.relabel_application_id + AND a.customer_id=t.customer_id AND a.relabel_no=t.relabel_no +SET t.status=CASE + WHEN COALESCE(t.fba_warehouse_code,'')<>'' THEN 'READY_FOR_DISPATCH' + ELSE 'WAIT_FBA_DESTINATION' END, + t.relabel_completed_at=COALESCE(t.relabel_completed_at,a.customer_confirmed_at), + t.updated_at=CURRENT_TIMESTAMP(3) +WHERE t.status='WAIT_RELABEL' + AND a.customer_confirmed_at IS NOT NULL + AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED'); + +UPDATE ll_order o +JOIN ll_warehouse_transfer_order t ON t.lastleg_order_id=o.id AND t.customer_id=o.customer_id +JOIN ll_relabel_application a ON a.id=t.relabel_application_id AND a.customer_id=t.customer_id +SET o.current_status=t.status, + o.relabel_status='COMPLETED', + o.sub_order_status=CONCAT('WAREHOUSE_TRANSFER:',t.status,';RELABEL:COMPLETED'), + o.updated_at=CURRENT_TIMESTAMP(3) +WHERE o.order_type='WAREHOUSE_TRANSFER' + AND o.current_status IN ('PENDING_ASSIGNMENT','WAIT_OUTBOUND','INBOUND_COMPLETED') + AND t.status IN ('READY_FOR_DISPATCH','WAIT_FBA_DESTINATION') + AND a.customer_confirmed_at IS NOT NULL + AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED'); + +UPDATE ll_relabel_application a +JOIN ll_warehouse_transfer_order t ON t.relabel_application_id=a.id AND t.customer_id=a.customer_id +SET a.fulfillment_status=CASE + WHEN t.status='READY_FOR_DISPATCH' THEN 'WAIT_PRE_DISPATCH' + ELSE 'WAIT_FBA_DESTINATION' END, + a.updated_at=CURRENT_TIMESTAMP(3) +WHERE a.customer_confirmed_at IS NOT NULL + AND a.status IN ('READY_FOR_OUTBOUND','COMPLETED') + AND t.status IN ('READY_FOR_DISPATCH','WAIT_FBA_DESTINATION') + AND a.fulfillment_status IN ('WAIT_RELABEL','WAIT_OUTBOUND_CREATION','WAIT_PRE_DISPATCH','READY_FOR_DISPATCH','WAIT_FBA_DESTINATION'); COMMIT; diff --git a/scripts/local-return-relabel-acceptance.mjs b/scripts/local-return-relabel-acceptance.mjs new file mode 100644 index 000000000..0621da00e --- /dev/null +++ b/scripts/local-return-relabel-acceptance.mjs @@ -0,0 +1,145 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { approveReturnFixture } from './admin-test-env-modules/phase4-overseas-return-receipt.mjs' +import { completeReturnRelabel, outboundRelabelledReturn } from './admin-test-env-modules/phase4-overseas-return-followups.mjs' + +const options = parseArguments(process.argv.slice(2)) +for (const url of [options.gateway, options.overseasGateway]) { + if (!['127.0.0.1', 'localhost'].includes(new URL(url).hostname)) throw new Error('Local gateways required') +} +const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID +if (!customerId || !options.username || !options.password || !process.env.INTERNAL_SERVICE_TOKEN) throw new Error('Explicit customer, credentials and internal token required') +const context = new AcceptanceRun(options) +const verifyRemaining = process.env.LOCAL_RETURN_VERIFY_REMAINING === 'true' +const receiptQuantity = verifyRemaining ? 3 : 1 +const output = resolve(options.output || `reports/local-return-relabel-single-${context.marker}.json`) +const moduleName = 'local-return-relabel-single' +let picker +let fatal = null +const facts = { marker: context.marker, customerId, scope: verifyRemaining ? 'REMAINING_RETURN_RELABEL' : 'ONE_NEW_RETURN_SKU_ON_EXISTING_LOCAL_CUSTOMER', photoEvidence: 'SYNTHETIC_TEST_IMAGE' } +context.context.merchantQualificationFixture = { marker: context.marker, tenantId: customerId } +context.createdResources.phase4OverseasReturnReceipt = facts +const success = response => response.http === 200 && String(response.body?.code) === '200' +async function save() { + await mkdir(dirname(output), { recursive: true }) + const summary = buildAcceptanceSummary(context.steps, [], []) + await writeFile(output, JSON.stringify({ success: !fatal && (verifyRemaining ? facts.remainingRelabelPassed === true : facts.relabelOutboundNo != null) && summary.failed === 0, fatal, summary, facts, steps: context.steps }, null, 2)) +} +function check(name, condition) { + context.assert(moduleName, name, null, Boolean(condition), '', { deploymentBlocker: true }) + if (!condition) throw new Error(name) +} +async function command(region, token, method, path, body, headers = {}) { + const response = region === 'cn' ? await context.request(method, path, body, token) + : await context.requestOverseas(method, path, body, headers, token) + context.addStep(moduleName, `${method} ${path}`, response, success(response), '', { deploymentBlocker: true }) + if (!success(response)) throw new Error(`${method} ${path}: ${response.body?.code} ${response.body?.message}; requestId=${response.requestId}; traceId=${response.traceId}`) + return response.body.data +} +async function upload(token, path, bytes, name, type) { + const form = new FormData() + form.append('file', new Blob([bytes], { type }), name) + const response = await context.requestOverseasMultipart('POST', path, form, token) + context.addStep(moduleName, `Upload ${path}`, response, success(response), '', { deploymentBlocker: true }) + if (!success(response)) throw new Error(`Upload failed: ${response.body?.code} ${response.body?.message}`) + return response.body.data +} +try { + const login = await command('cn', '', 'POST', '/api/auth/login', { loginId: options.username, password: options.password }) + context.token = login.accessToken + context.overseasToken = login.overseasAccessToken + const ticket = await command('cn', context.token, 'POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${context.marker} 本地单票退货换标验收` }) + const merchant = await command('cn', '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) + const token = merchant.overseasAccessToken + const permissions = await command('cn', merchant.accessToken, 'GET', '/api/merchant/v1/iam/authorization/permissions') + check('Merchant return create and relabel permissions', permissions.includes('merchant:overseas-warehouse:return-order:create') && permissions.includes('merchant:overseas-warehouse:return-order:relabel')) + await command('os', token, 'GET', '/api/merchant/v1/last-leg/return-orders?page=1&size=1') + const warehouses = await command('cn', context.token, 'GET', '/api/platform/v1/warehouse-admin?page=1&size=200&warehouseType=OVERSEAS&status=ACTIVE&capabilityCode=RECEIVING') + const warehouse = warehouses.records.find(row => row.id === process.env.OVERSEAS_TEST_WAREHOUSE_ID) || warehouses.records[0] + check('Existing active overseas receiving warehouse', warehouse?.id) + const warehouseId = String(warehouse.id) + facts.warehouseId = warehouseId + let destination + for (let page = 1; page <= 20; page++) { + const data = await command('cn', context.token, 'GET', `/api/platform/v1/warehouse-admin?page=${page}&size=200&warehouseType=FBA&status=ACTIVE`) + destination = data.records.find(row => row.warehouseCode === 'SCK8') + if (destination || page * 200 >= Number(data.total)) break + } + check('Existing active SCK8 destination', destination?.id) + const access = await resolvePickerAccess(context) + check('PDA role available', access) + picker = await createPicker(context, 93, warehouseId, access) + check('PDA session ready', picker?.token) + const skuCode = `LOCAL-RETURN-${Date.now()}` + facts.skuCode = skuCode + const root = '/api/merchant/v1/last-leg/return-orders' + const receipts = '/api/pda/v1/last-leg/return-receipts' + const created = await command('os', token, 'POST', root, { overseasWarehouseId: warehouseId, warehouseContactName: '本地验收', warehouseContactPhone: '0000000000', customerReferenceNo: context.marker, needRelabel: false, remark: context.marker, idempotencyKey: `${context.marker}-CREATE`, items: [{ skuCode, productNameZh: '本地退货换标验收商品', expectedQuantity: receiptQuantity }] }) + const returnNo = created.order.returnNo + facts.returnNo = returnNo + await save() + await approveReturnFixture(context, token, created) + await command('os', picker.token, 'POST', `${receipts}/start`, { returnNo }) + const pallet = await command('os', picker.token, 'POST', `${receipts}/pallets`, { returnNo, skuCode }) + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64') + await upload(picker.token, `${receipts}/${returnNo}/photos/files`, png, 'synthetic-receipt.png', 'image/png') + const receiptBody = { returnNo, startedBy: 'RETURN_NO', palletCode: pallet.palletCode, items: [{ skuCode, receivedQuantity: receiptQuantity }], idempotencyKey: `${context.marker}-RECEIPT` } + const received = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody) + const replay = await command('os', picker.token, 'POST', `${receipts}/confirm`, receiptBody) + check('Receipt completed once with one stock and fee batch', received.orderStatus === 'COMPLETED' && Number(received.receivedQuantity) === receiptQuantity && received.receiptNo === replay.receiptNo && received.receiptBatchNo === replay.receiptBatchNo) + facts.receiptNo = received.receiptNo + facts.receiptBatchNo = received.receiptBatchNo + facts.sourcePalletCode = pallet.palletCode + await save() + const relabel = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, { items: [{ skuCode, quantity: 1 }], fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: String(destination.id), destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: context.marker, idempotencyKey: `${context.marker}-RELABEL` }) + const relabelNo = relabel.relabelNo + check('Relabel references the new return', relabel.returnNo === returnNo && relabel.status === 'PENDING_REVIEW' && relabelNo) + facts.relabelNo = relabelNo + await save() + const rendered = await fetch('http://127.0.0.1:9207/internal/print/v1/pdfs/inbound-marks', { method: 'POST', headers: { 'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN, 'X-Operator-Id': '1', 'Content-Type': 'application/json' }, body: JSON.stringify({ templateCode: 'INBOUND_MARK_V1', pageSpec: { widthMm: 100, heightMm: 100 }, downloadFileName: 'local-return-relabel.pdf', labels: [{ topCode: skuCode, warehouseText: destination.warehouseCode, cartonText: 'QTY: 1', routeText: 'LOCAL ACCEPTANCE', qrContent: relabelNo }] }) }) + const pdf = Buffer.from(await rendered.arrayBuffer()) + check('Print service returned real PDF', rendered.ok && pdf.subarray(0, 5).toString() === '%PDF-') + facts.labelPdfSha256 = createHash('sha256').update(pdf).digest('hex') + const uploaded = await upload(token, `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/files?scope=CARTON_TOTAL`, pdf, 'local-return-relabel.pdf', 'application/pdf') + facts.labelFileId = uploaded.fileId + await completeReturnRelabel(context, token, picker.token, relabelNo, returnNo) + await save() + if (verifyRemaining) { + const candidates = await command('os', token, 'GET', `${root}/${returnNo}/relabel-candidates`) + check('Two unlabelled pieces remain on the source pallet', candidates.filter(row => row.cartonMark === pallet.palletCode).flatMap(row => row.items).reduce((sum, row) => sum + Number(row.receivedQuantity), 0) === 2) + const body = { items: [{ skuCode, quantity: 1 }], fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: String(destination.id), destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: `${context.marker}-SECOND`, idempotencyKey: `${context.marker}-RELABEL-SECOND` } + const second = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, body) + check('Second application accepts remaining goods on same return', second.returnNo === returnNo && second.relabelNo !== relabelNo && second.status === 'PENDING_REVIEW') + facts.secondRelabelNo = second.relabelNo + await save() + const repeated = await command('os', token, 'POST', `${root}/${returnNo}/relabel`, body) + check('Second application replay does not duplicate allocation', repeated.relabelNo === second.relabelNo) + await upload(token, `/api/merchant/v1/last-leg/relabel-applications/${second.relabelNo}/files?scope=CARTON_TOTAL`, pdf, 'local-return-second.pdf', 'application/pdf') + context.marker = `${facts.marker}-SECOND` + try { + await completeReturnRelabel(context, token, picker.token, second.relabelNo, returnNo) + } finally { + context.marker = facts.marker + } + const after = await command('os', token, 'GET', `${root}/${returnNo}/relabel-candidates`) + check('Exactly one unlabelled piece remains after two completed batches', after.filter(row => row.cartonMark === pallet.palletCode).flatMap(row => row.items).reduce((sum, row) => sum + Number(row.receivedQuantity), 0) === 1) + facts.remainingRelabelPassed = true + } else { + await outboundRelabelledReturn(context, token, picker.token, relabelNo, returnNo, warehouseId) + } + const final = (await command('os', token, 'GET', `${root}/${returnNo}`)).order + facts.finalReturnStatus = final.status + facts.finalRelabelStatus = final.relabelStatus +} catch (error) { + fatal = error.message + process.exitCode = 1 +} finally { + if (picker?.membershipId) { + try { await disablePicker(context, picker, 93) } catch (error) { fatal = `${fatal || ''} cleanup: ${error.message}`; process.exitCode = 1 } + } + await save() + console.log(JSON.stringify({ output, fatal, facts, summary: buildAcceptanceSummary(context.steps, [], []), failures: context.steps.filter(step => !step.passed) }, null, 2)) +} diff --git a/scripts/local-return-relabel-outbound.mjs b/scripts/local-return-relabel-outbound.mjs new file mode 100644 index 000000000..f0abe3c74 --- /dev/null +++ b/scripts/local-return-relabel-outbound.mjs @@ -0,0 +1,72 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' +import { AcceptanceRun, parseArguments, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' + +const options = parseArguments(process.argv.slice(2)) +for (const url of [options.gateway, options.overseasGateway]) { + if (!['127.0.0.1', 'localhost'].includes(new URL(url).hostname)) throw new Error('Local only') +} +if (!process.env.LOCAL_RETURN_SOURCE) throw new Error('LOCAL_RETURN_SOURCE required') +const previous = JSON.parse(await readFile(process.env.LOCAL_RETURN_SOURCE, 'utf8')) +const f = previous.facts +if (!f.relabelCompleted || !f.returnNo || !f.relabelNo || !f.customerId || !f.warehouseId) throw new Error('Completed relabel evidence required') +const c = new AcceptanceRun(options) +const output = options.output || `reports/local-return-relabel-outbound-${c.marker}.json` +let picker, error = null +const report = { source: process.env.LOCAL_RETURN_SOURCE, facts: f, scope: 'PLATFORM_VERIFIED_OUTBOUND_ONLY', merchantInventoryRead: 'NOT_VERIFIED_SEE_SOURCE_REPORT' } +function check(name, ok) { + c.assert('local-outbound', name, null, Boolean(ok), '', { deploymentBlocker: true }) + if (!ok) throw new Error(name) +} +async function api(region, token, method, path, body) { + const r = region === 'cn' ? await c.request(method, path, body, token) : await c.requestOverseas(method, path, body, {}, token) + const ok = r.http === 200 && String(r.body?.code) === '200' + c.addStep('local-outbound', `${method} ${path}`, r, ok, '', { deploymentBlocker: true }) + if (!ok) throw new Error(`${path}: ${r.body?.code} ${r.body?.message}`) + return r.body.data +} +async function save() { + await mkdir(dirname(output), { recursive: true }) + await writeFile(output, JSON.stringify({ ...report, error, summary: buildAcceptanceSummary(c.steps, [], []), steps: c.steps }, null, 2)) +} +try { + const l = await api('cn', '', 'POST', '/api/auth/login', { loginId: options.username, password: options.password }) + c.token = l.accessToken; c.overseasToken = l.overseasAccessToken + const inv = '/api/platform/v1/last-leg/return-inventory/cartons' + const root = '/api/platform/v1/last-leg/outbound-orders' + const candidates = await api('os', c.overseasToken, 'GET', `${root}/return/candidates?customerId=${f.customerId}&warehouseId=${f.warehouseId}`) + const matches = [] + for (const row of candidates.filter(x => x.returnNo === f.returnNo)) { + const d = await api('os', c.overseasToken, 'GET', `${inv}/${encodeURIComponent(row.cartonMark)}`) + if (d.relabelNo === f.relabelNo) matches.push(d) + } + check('Exactly one matching relabelled return carton', matches.length === 1) + const d = matches[0] + check('Customer warehouse quantity and completed relabel match', d.customerId === f.customerId && d.overseasWarehouseId === f.warehouseId && d.sourceReturnNo === f.returnNo && d.status === 'AVAILABLE' && d.relabelStatus === 'COMPLETED' && Number(d.totalQuantity) === 1) + const access = await resolvePickerAccess(c) + check('PDA role', access) + picker = await createPicker(c, 94, f.warehouseId, access) + check('PDA ready', picker?.token) + const body = { recipient: 'LOCAL ACCEPTANCE', overseasWarehouseId: f.warehouseId, returnNo: f.returnNo, cartonMarks: [d.cartonMark], remark: f.marker, idempotencyKey: `${f.marker}-OUTBOUND` } + const task = await api('os', c.overseasToken, 'POST', `${root}/return?customerId=${f.customerId}`, body) + report.outboundNo = task.outboundNo; report.taskNo = task.taskNo + await save() + const replay = await api('os', c.overseasToken, 'POST', `${root}/return?customerId=${f.customerId}`, body) + check('Outbound idempotent', replay.outboundNo === task.outboundNo && replay.taskNo === task.taskNo) + const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}` + const detail = await api('os', picker.token, 'GET', path) + const frozen = await api('os', c.overseasToken, 'GET', `${root}/${task.outboundNo}?customerId=${f.customerId}`) + check('Frozen outbound contains only matching carton', detail.items.length === 1 && Number(detail.items[0].packageCount) === 1 && frozen.pallets.length === 1 && frozen.pallets[0].cartonMarks === d.cartonMark && String(frozen.pallets[0].id) === String(detail.items[0].outboundPalletItemId)) + await api('os', picker.token, 'POST', `${path}/pick`, { outboundPalletItemId: detail.items[0].outboundPalletItemId, inventoryPositionId: null, packages: 1, cartonMarks: [d.cartonMark], idempotencyKey: `${f.marker}-PICK` }) + const done = await api('os', picker.token, 'POST', `${path}/complete`, { idempotencyKey: `${f.marker}-OUTBOUND-COMPLETE` }) + const final = await api('os', c.overseasToken, 'GET', `${root}/${task.outboundNo}?customerId=${f.customerId}`) + const stock = await api('os', c.overseasToken, 'GET', `${inv}/${encodeURIComponent(d.cartonMark)}`) + check('Outbound completed and stock OUTBOUNDED', done.status === 'COMPLETED' && final.outbound.status === 'OUTBOUNDED' && stock.status === 'OUTBOUNDED' && stock.relabelNo === f.relabelNo && stock.sourceReturnNo === f.returnNo && Number(stock.totalQuantity) === 1) + report.finalOutboundStatus = final.outbound.status; report.finalInventoryStatus = stock.status; report.fulfillmentPassed = true +} catch (e) { error = e.message; process.exitCode = 1 } +finally { + if (picker?.membershipId) { try { await disablePicker(c, picker, 94) } catch (e) { error = `${error || ''} cleanup: ${e.message}`; process.exitCode = 1 } } + await save() + console.log(JSON.stringify({ output, error, outboundNo: report.outboundNo, finalOutboundStatus: report.finalOutboundStatus, finalInventoryStatus: report.finalInventoryStatus, summary: buildAcceptanceSummary(c.steps, [], []), failures: c.steps.filter(x => !x.passed) }, null, 2)) +}