Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -72,6 +72,8 @@ public record LastlegOrderSummary(
|
||||
String customerRemark,
|
||||
String warehouseRemark,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
LocalDateTime updatedAt,
|
||||
@Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序")
|
||||
List<RelabelSubOrder> relabelApplications
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
@@ -16,6 +16,11 @@ import java.math.BigDecimal;
|
||||
|
||||
@Mapper
|
||||
public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
@Select("<script>SELECT r.lastleg_order_id,r.relabel_no,r.status,r.notified_package_count AS quantity,r.created_at " +
|
||||
"FROM ll_relabel_application r JOIN ll_order o ON o.id=r.lastleg_order_id AND o.customer_id=r.customer_id " +
|
||||
"WHERE r.lastleg_order_id IN <foreach collection='orderIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"ORDER BY r.created_at,r.id</script>")
|
||||
List<com.baiyue.lastleg.dto.RelabelSubOrder> findRelabelSubOrders(@Param("orderIds") List<Long> orderIds);
|
||||
String WAREHOUSE_FILTER_SQL = "(LOWER(TRIM(overseas_warehouse_code)) LIKE CONCAT('%',LOWER({0}),'%') " +
|
||||
"OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))";
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ public final class RelabelResponses {
|
||||
String destinationFbaWarehouseName, String skuCode, String productNameZh,
|
||||
long availablePackages, long version, List<InventoryPosition> positions,
|
||||
String sourceType, Long returnCartonId, String cartonMark,
|
||||
List<SkuItem> skuItems) {}
|
||||
List<SkuItem> skuItems, long relabelledPackages) {}
|
||||
public record HistoricalOrder(Long id, String orderNo, String sourceOrderNo, String outboundOrderNo,
|
||||
String relabelOrderNo, String orderType, String status, Integer packageCount,
|
||||
LocalDateTime createdAt) {}
|
||||
|
||||
+35
-17
@@ -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<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();
|
||||
.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<String> destinations = relabelItems.stream()
|
||||
.map(RelabelItem::getDestinationFbaWarehouseCode)
|
||||
|
||||
+2
-1
@@ -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<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications) { }
|
||||
public record Item(String skuCode, String productNameZh, long expectedQuantity,
|
||||
long receivedQuantity) { }
|
||||
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos) { }
|
||||
|
||||
+12
@@ -36,6 +36,12 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
|
||||
@Select("SELECT * FROM ll_return_inventory_carton WHERE id=#{id} FOR UPDATE")
|
||||
ReturnInventoryCarton lockId(@Param("id") long id);
|
||||
|
||||
@Select("SELECT COALESCE(SUM(ri.notified_package_count),0) FROM ll_relabel_item ri " +
|
||||
"JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
|
||||
"WHERE ri.return_carton_id=#{cartonId} AND ri.sku_code=#{skuCode} " +
|
||||
"AND ra.status NOT IN ('CANCELLED','REJECTED')")
|
||||
long relabelledQuantity(@Param("cartonId") long cartonId, @Param("skuCode") String skuCode);
|
||||
|
||||
@Update("UPDATE ll_return_inventory_carton SET total_quantity=#{remaining},sku_types=#{skuTypes},status='AVAILABLE'," +
|
||||
"relabel_application_id=NULL,relabel_no=NULL,relabel_status=NULL,updated_at=NOW(3) " +
|
||||
"WHERE id=#{id} AND customer_id=#{customerId} AND relabel_application_id=#{applicationId} " +
|
||||
@@ -44,6 +50,12 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
|
||||
@Param("applicationId") long applicationId, @Param("remaining") long remaining,
|
||||
@Param("skuTypes") int skuTypes);
|
||||
|
||||
@Update("UPDATE ll_return_inventory_carton SET relabel_application_id=#{applicationId},relabel_no=#{relabelNo}," +
|
||||
"relabel_status='PENDING',status='RELABEL_PENDING',updated_at=#{updatedAt} " +
|
||||
"WHERE id=#{id} AND status='AVAILABLE' AND sku_types>0 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);
|
||||
|
||||
|
||||
+2
@@ -13,6 +13,8 @@ public interface ReturnOrderMapper extends BaseMapper<ReturnOrder> {
|
||||
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} " +
|
||||
|
||||
+26
-10
@@ -186,7 +186,9 @@ public class ReturnReceiptService {
|
||||
w.in(ReturnOrder::getId, ids);
|
||||
}
|
||||
Page<ReturnOrder> 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<ReturnOrderResponses.Photo> 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<String, ReturnOrderItem> 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<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabelSubOrders(List<ReturnOrder> 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<com.baiyue.lastleg.dto.RelabelSubOrder> 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(),
|
||||
|
||||
@@ -96,10 +96,12 @@ public class LastlegOrderService {
|
||||
Map<String, String> containerNodes = containerNodes(page.getRecords());
|
||||
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
|
||||
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords());
|
||||
Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> 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<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabelSubOrders(List<LastlegOrder> 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<LastlegOrderMapper.PalletMergeRelationRow> relations,
|
||||
String containerNode, String dispatchStatus,
|
||||
LastlegOrderMapper.OrderWeightRow weight) {
|
||||
LastlegOrderMapper.OrderWeightRow weight,
|
||||
List<com.baiyue.lastleg.dto.RelabelSubOrder> relabelApplications) {
|
||||
List<LastlegOrderMapper.PalletMergeRelationRow> 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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"));
|
||||
|
||||
+29
-2
@@ -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");
|
||||
|
||||
+3
-2
@@ -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<RelabelResponses.InventoryCandidate> 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());
|
||||
}
|
||||
}
|
||||
|
||||
+71
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+11
-1
@@ -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-<marker>.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-<marker>.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`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。
|
||||
|
||||
|
||||
@@ -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') || '',
|
||||
|
||||
@@ -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` 状态,再执行暂扣、同键重放/异参冲突、解除。正向暂扣未执行或前置不足仍是失败,不以入库前拒绝代替正向通过。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user