贴标申请,仓储转运

This commit is contained in:
2026-09-22 08:49:21 +08:00
parent 9147190d67
commit 9a8412279c
28 changed files with 549 additions and 48 deletions
+2
View File
@@ -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) {}
@@ -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)
@@ -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) { }
@@ -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);
@@ -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} " +
@@ -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"));
@@ -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");
@@ -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());
}
}
@@ -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;
@@ -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
View File
@@ -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`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。
+1
View File
@@ -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') || '',
+2
View File
@@ -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;
+145
View File
@@ -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))
}
+72
View File
@@ -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))
}