贴标申请,仓储转运

This commit is contained in:
2026-09-20 15:06:17 +08:00
parent a7d458cddd
commit 02844da360
56 changed files with 2121 additions and 111 deletions
@@ -1,4 +1,8 @@
spring:
cloud:
nacos:
discovery:
ip: ${NACOS_DISCOVERY_IP:127.0.0.1}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
@@ -2314,6 +2314,7 @@ CREATE TABLE ll_relabel_pda_unit_fact (
result varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码',
reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因',
quantity int NOT NULL DEFAULT '1' COMMENT '本次事实处理件数,箱级作业固定为1',
operator_key varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识',
operator_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照',
occurred_at datetime(3) NOT NULL COMMENT '逐件事实发生时间',
@@ -2360,6 +2361,7 @@ CREATE TABLE ll_return_inventory_carton (
source_receipt_id bigint DEFAULT NULL COMMENT '来源PDA签收单ID',
source_batch_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔',
source_return_no text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码',
fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛',
destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码',
@@ -2382,8 +2384,8 @@ CREATE TABLE ll_return_inventory_carton (
UNIQUE KEY uk_ll_return_inventory_carton_mark (carton_mark),
UNIQUE KEY uk_ll_return_inventory_carton_receipt (source_receipt_id),
KEY idx_ll_return_inventory_carton_customer (customer_id,overseas_warehouse_id,status,created_at),
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))),
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED')))
CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUNDED')) and (sku_types > 0) and (total_quantity > 0)) or ((status = _utf8mb4'MERGED') and (sku_types = 0) and (total_quantity = 0)))),
CONSTRAINT chk_ll_return_inventory_carton_status CHECK ((status in (_utf8mb4'AVAILABLE',_utf8mb4'RELABEL_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛';
CREATE TABLE ll_return_inventory_carton_item (
@@ -2432,6 +2434,8 @@ CREATE TABLE ll_return_order (
expected_quantity bigint NOT NULL COMMENT '预报总数量',
received_quantity bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注',
discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手动完结差异原因',
manually_completed_at datetime(3) DEFAULT NULL COMMENT '手动差异完结时间',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
request_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256',
created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID',
@@ -2466,6 +2470,7 @@ CREATE TABLE ll_return_receipt (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键',
receipt_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号',
receipt_batch_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号',
pallet_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码',
carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号',
tracking_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号',
bound_by varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选',
@@ -44,6 +44,15 @@ public class InternalBusinessFileController {
return ApiResponse.success(service.upload(tenantId, businessType, businessId, operatorId, file));
}
@GetMapping("/{tenantId}/{businessType}/{businessId}")
public ApiResponse<java.util.List<InternalBusinessFileService.Metadata>> list(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable @NotBlank String businessType,
@PathVariable @NotBlank String businessId) {
check(token); checkTenantId(tenantId);
return ApiResponse.success(service.list(tenantId, businessType, businessId));
}
@GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
public ApiResponse<InternalBusinessFileService.Metadata> metadata(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class InternalBusinessFileService {
@Resource private FileObjectMapper files;
@@ -52,6 +53,16 @@ public class InternalBusinessFileService {
return metadata(required(tenantId, businessType, businessId, fileId));
}
public List<Metadata> list(long tenantId, String businessType, String businessId) {
BusinessFileCategory category = BusinessFileCategory.require(businessType);
category.validateOwner(tenantId); category.storageBusinessId(businessId);
return files.selectList(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId).eq(FileObjectEntity::getBusinessType, businessType)
.eq(FileObjectEntity::getBusinessId, businessId).eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false).orderByAsc(FileObjectEntity::getCreatedAt, FileObjectEntity::getId))
.stream().map(InternalBusinessFileService::metadata).toList();
}
public Download download(long tenantId, String businessType, String businessId, long fileId) {
FileObjectEntity value = required(tenantId, businessType, businessId, fileId);
return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey()));
@@ -39,7 +39,17 @@ public class DeliveryCompletedOutboxService {
Objects.requireNonNull(completedAt, "completedAt");
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
if ("RELABEL_APPLICATION".equals(order.getOrderType())) continue;
// Only orders projected from the domestic first-leg domain need an
// acknowledgement event. Customer/local overseas orders can still carry
// a local sourceInboundId, but deliberately have no domestic sourceOrderId.
if (StringUtils.hasText(order.getSourceRegion())
&& !RegionCode.CN.name().equalsIgnoreCase(order.getSourceRegion())) continue;
// Relabel and warehouse-transfer deliveries are created and completed wholly
// in the overseas last-leg domain. They do not have a domestic source order
// to acknowledge, so publishing the cross-region event would make receipt
// completion fail on the missing source-order fields.
if ("RELABEL_APPLICATION".equals(order.getOrderType())
|| "WAREHOUSE_TRANSFER".equals(order.getOrderType())) continue;
if (!StringUtils.hasText(order.getSourceInboundId())) continue;
requireText(order.getSourceOrderId(), "sourceOrderId");
requireText(order.getInboundCode(), "inboundCode");
@@ -1,24 +1,36 @@
package com.baiyue.lastleg.palletops.dto;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.time.LocalDateTime;
import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record RelabelSource(long positionId, long positionVersion, String palletCode,
public record RelabelSource(
@JsonSerialize(using = ToStringSerializer.class) long positionId,
long positionVersion,
@JsonSerialize(using = ToStringSerializer.class) long palletId, String palletCode,
String locationCode, String inboundCode, long stockPackages,
long movablePackages, List<String> cartonMarks) { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
public record InventoryLine(String customerId, String customerName,
@JsonSerialize(using = ToStringSerializer.class) long balanceId,
@JsonSerialize(using = ToStringSerializer.class) long positionId,
String inboundCode, String skuCode, String productName,
long availablePackages, long holdPackages, long allocatedPackages,
long pickedPackages, long receivingPackages, long positionVersion) { }
public record PalletContent(long palletId, String palletCode, String status, int palletVersion,
Long locationId, String locationCode, String destinationWarehouseCode,
public record PalletContent(@JsonSerialize(using = ToStringSerializer.class) long palletId,
String palletCode, String status, int palletVersion,
@JsonSerialize(using = ToStringSerializer.class) Long locationId,
String locationCode, String destinationWarehouseCode,
long totalPackages, List<InventoryLine> inventory) { }
public record PrintablePallet(long palletId, String palletCode, int palletVersion,
public record PrintablePallet(@JsonSerialize(using = ToStringSerializer.class) long palletId,
String palletCode, int palletVersion,
String relabelNo, String printableHtml) { }
public record Operation(long operationId, String operationType, String idempotencyKey,
public record Operation(@JsonSerialize(using = ToStringSerializer.class) long operationId,
String operationType, String idempotencyKey,
String status, boolean replayed, LocalDateTime completedAt,
List<PalletContent> pallets) {
public Operation asReplay() {
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.palletops.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
@@ -30,7 +31,14 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
@Override
public PalletReference ensureRelabelPallet(long warehouseId, String businessId) {
Map<String, Object> body = Map.of("warehouseId", warehouseId, "businessId", businessId);
return ensureRelabelPallet(warehouseId, businessId, null);
}
@Override
public PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("warehouseId", warehouseId); body.put("businessId", businessId);
if (fbaWarehouseCode != null && !fbaWarehouseCode.isBlank()) body.put("fbaWarehouseCode", fbaWarehouseCode.trim());
ResolveEnvelope response = client.post().uri("/internal/v1/warehouse-pallets/ensure-relabel")
.header("X-Internal-Service-Token", internalToken).body(body).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> {
@@ -117,7 +125,10 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway {
}).toBodilessEntity();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record ResolveEnvelope(String code, String message, PalletReference data) { }
@JsonIgnoreProperties(ignoreUnknown = true)
private record LocationEnvelope(LocationReference data) { }
@JsonIgnoreProperties(ignoreUnknown = true)
private record TransitionEnvelope(Transition data) { }
}
@@ -1,7 +1,12 @@
package com.baiyue.lastleg.palletops.gateway;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
public interface PalletPhysicalGateway {
PalletReference ensureRelabelPallet(long warehouseId, String businessId);
default PalletReference ensureRelabelPallet(long warehouseId, String businessId, String fbaWarehouseCode) {
return ensureRelabelPallet(warehouseId, businessId);
}
PalletReference resolve(String palletCode);
PalletReference resolve(long palletId);
LocationReference resolveLocation(long warehouseId, long locationId);
@@ -9,6 +14,7 @@ public interface PalletPhysicalGateway {
String operationKey, String reason);
void compensate(long warehouseId, Transition transition);
@JsonIgnoreProperties(ignoreUnknown = true)
record PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
String status, String fbaWarehouseCode, java.math.BigDecimal unitPrice, int version) {
public PalletReference(long palletId, String palletCode, long warehouseId, Long locationId,
@@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -48,6 +53,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class PdaPalletOperationService {
@@ -61,6 +67,9 @@ public class PdaPalletOperationService {
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelPalletMutationService relabelPalletMutations;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ReturnInventoryBalanceMapper returnBalances;
private TransactionTemplate transaction;
private TransactionTemplate newTransaction;
@@ -85,6 +94,26 @@ public class PdaPalletOperationService {
Set<String> boundMarks = boundRelabelMarks(app);
List<RelabelItem> items = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()));
if (!items.isEmpty() && items.stream().allMatch(item -> item.getReturnCartonId() != null)) {
List<PalletOperationResponses.RelabelSource> result = new ArrayList<>();
Map<Long, List<RelabelItem>> byCarton = items.stream().filter(item -> !boundMarks.contains(item.getCartonMark())).collect(Collectors.groupingBy(
RelabelItem::getReturnCartonId, LinkedHashMap::new, Collectors.toList()));
for (var entry : byCarton.entrySet()) {
ReturnInventoryCarton carton = returnCartons.selectById(entry.getKey());
if (carton == null || !Objects.equals(carton.getCustomerId(), app.getCustomerId())
|| !Objects.equals(carton.getOverseasWarehouseId(), app.getOverseasWarehouseId())
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
throw conflict("退货换标来源托盘不存在或已被占用");
PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode());
if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId())
throw conflict("退货换标来源托盘不属于当前仓库");
long quantity = value(carton.getTotalQuantity());
result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L,
pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity,
entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList()));
}
return result;
}
Map<Long, List<RelabelItem>> byBalance = new LinkedHashMap<>();
for (RelabelItem item : items) {
InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId());
@@ -99,7 +128,7 @@ public class PdaPalletOperationService {
long ownHold = group.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), row.getPositionId()))
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
result.add(new PalletOperationResponses.RelabelSource(row.getPositionId(), value(row.getVersion()),
row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
row.getPalletId(), row.getPalletNo(), row.getWarehouseLocationCode(), row.getInboundCode(),
value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()),
value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())),
@@ -118,15 +147,22 @@ public class PdaPalletOperationService {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
String key = required(request.idempotencyKey(), "幂等键不能为空");
String destination = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", application.getId()).eq("customer_id", application.getCustomerId()))
.stream().map(RelabelItem::getDestinationFbaWarehouseCode).filter(value -> value != null && !value.isBlank())
.distinct().reduce((left, right) -> { throw conflict("同一换标单只能绑定一个 FBA 目的仓"); }).orElse(null);
PalletPhysicalGateway.PalletReference pallet = physical.ensureRelabelPallet(identity.overseasWarehouseId(),
relabelNo + ":" + key);
relabelNo + ":" + key, destination);
String code = HtmlUtils.htmlEscape(pallet.palletCode());
String order = HtmlUtils.htmlEscape(relabelNo);
String fbaValue = destination == null ? pallet.fbaWarehouseCode() : destination;
String fba = HtmlUtils.htmlEscape(fbaValue == null ? "" : fbaValue);
if (fba == null || fba.isBlank()) fba = "待绑定";
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code
+ "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}"
+ "h1{font-size:24px;margin:8mm 0 4mm;letter-spacing:0}.code{font-size:18px;font-weight:700;word-break:break-all}"
+ ".ref{font-size:12px;margin-top:6mm}</style></head><body><h1>托盘标签</h1><div class=\"code\">"
+ code + "</div><div class=\"ref\">换标单:" + order + "</div></body></html>";
+ code + "</div><div class=\"ref\">换标单:" + order + "<br>FBA目的仓:" + fba + "</div></body></html>";
return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(),
relabelNo, html);
}
@@ -205,8 +241,10 @@ public class PdaPalletOperationService {
Map<Long, Long> packagesByPosition = new LinkedHashMap<>();
Map<Long, List<RelabelItem>> itemsByPosition = new LinkedHashMap<>();
Map<Long, InventoryPosition> anchors = new LinkedHashMap<>();
InventoryPosition actual = request.actualSource() == null ? null : positions.selectById(request.actualSource().positionId());
if (request.actualSource() != null && (actual == null
boolean returnRelabel = selected.stream().allMatch(item -> item.getReturnCartonId() != null);
InventoryPosition actual = returnRelabel || request.actualSource() == null
? null : positions.selectById(request.actualSource().positionId());
if (!returnRelabel && request.actualSource() != null && (actual == null
|| !Objects.equals(actual.getCustomerId(), application.getCustomerId())
|| !Objects.equals(actual.getPalletId(), source.palletId())
|| !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId())
@@ -219,13 +257,17 @@ public class PdaPalletOperationService {
if ("PROCESSING".equals(application.getStatus())) {
var workTask = relabelWorkTasks.find(application.getCustomerId(), application.getId());
for (RelabelItem item : selected) {
Long completed = relabelWorkFacts.selectCount(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
.eq("item_id", item.getId()).eq("result", "RELABELLED"));
if (completed == null || completed < value(item.getNotifiedPackageCount()))
long completed = relabelWorkFacts.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact>()
.eq("task_id", workTask.getId()).eq("customer_id", application.getCustomerId())
.eq("item_id", item.getId()).eq("result", "RELABELLED"))
.stream().mapToLong(fact -> fact.getQuantity() == null ? 1L : fact.getQuantity()).sum();
if (completed < value(item.getNotifiedPackageCount()))
throw conflict("请先完成本箱全部换标作业,再进行组托");
}
}
if (returnRelabel) {
return bindReturnRelabelPallet(identity, application, selected, source, target, request);
}
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
@@ -265,6 +307,107 @@ public class PdaPalletOperationService {
reason, relabelPlan);
}
private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity,
RelabelApplication application, List<RelabelItem> selected,
PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target,
PalletOperationRequests.RelabelDisassemble request) {
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Set<String> alreadyBound = boundRelabelMarks(application);
if (selected.stream().map(RelabelItem::getCartonMark).anyMatch(alreadyBound::contains))
throw conflict("退货换标箱已在本轮完成组托,请刷新后重试");
long quantity = 0;
List<ReturnInventoryCarton> cartons = new ArrayList<>();
for (RelabelItem item : selected) {
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !Objects.equals(carton.getStatus(), "RELABEL_PENDING"))
throw conflict("退货换标来源托盘或库存状态已变化,请刷新后重试");
quantity += value(item.getNotifiedPackageCount());
cartons.add(carton);
}
if (request.actualSource() == null || request.actualSource().positionId() != cartons.get(0).getId()
|| request.actualSource().packages() != quantity)
throw conflict("退货换标拆出数量必须等于本批已完成贴标数量");
String key = required(request.idempotencyKey(), "幂等键不能为空");
String requestHash = hash(request);
PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash);
if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay();
physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托");
return transaction.execute(status -> {
for (int index = 0; index < cartons.size(); index++) {
RelabelItem item = selected.get(index);
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId())
|| !Objects.equals(carton.getPalletCode(), source.palletCode())
|| !Objects.equals(carton.getRelabelApplicationId(), application.getId())
|| !"RELABEL_PENDING".equals(carton.getStatus()))
throw conflict("退货换标来源库存已变化,请刷新后重试");
long move = value(item.getNotifiedPackageCount());
if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足");
if (move < value(carton.getTotalQuantity())) {
splitReturnRelabelCarton(application, item, carton, target, move);
} else {
carton.setPalletCode(target.palletCode());
carton.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode());
carton.setRelabelStatus("PROCESSING");
carton.setUpdatedAt(LocalDateTime.now());
returnCartons.updateById(carton);
}
}
recordReturnRelabelBinding(identity, application, selected, target.palletCode());
List<PalletOperationResponses.PalletContent> contents = List.of(
content(identity.overseasWarehouseId(), physical.resolve(source.palletCode())),
content(identity.overseasWarehouseId(), physical.resolve(target.palletCode())));
PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(),
operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents);
operation.setStatus("COMPLETED"); operation.setResultJson(write(result));
operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation);
return result;
});
}
/** Keep the unlabelled remainder on its original pallet; move only the confirmed SKU pieces. */
private void splitReturnRelabelCarton(RelabelApplication application, RelabelItem item,
ReturnInventoryCarton source, PalletPhysicalGateway.PalletReference target, long quantity) {
if (relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("application_id", application.getId()).eq("return_carton_id", source.getId())).size() != 1)
throw conflict("来源托盘包含多项换标任务,不能按单 SKU 拆分,请核对库存");
List<ReturnInventoryCartonItem> lines = returnCartonItems.lockCarton(source.getId());
if (lines.size() != 1 || !item.getSkuCode().equalsIgnoreCase(lines.get(0).getSkuCode())
|| value(lines.get(0).getQuantity()) != value(source.getTotalQuantity()))
throw conflict("来源托盘包含其他 SKU,不能按单 SKU 拆分,请核对库存");
long remaining = value(source.getTotalQuantity()) - quantity;
ReturnInventoryCartonItem sourceLine = lines.get(0);
sourceLine.setQuantity(remaining);
if (returnCartonItems.updateById(sourceLine) != 1
|| returnCartons.releasePartial(source.getId(), application.getCustomerId(), application.getId(), remaining) != 1)
throw conflict("来源托盘数量已变化,请刷新后重试");
LocalDateTime now = LocalDateTime.now();
long targetId = IdWorker.getId();
ReturnInventoryCarton moved = new ReturnInventoryCarton();
moved.setId(targetId); moved.setCustomerId(source.getCustomerId());
moved.setOverseasWarehouseId(source.getOverseasWarehouseId());
moved.setSourceBatchNo(source.getSourceBatchNo()); moved.setSourceReturnNo(source.getSourceReturnNo());
moved.setPalletCode(target.palletCode()); moved.setCartonMark("RL-CARTON-" + targetId);
moved.setDestinationWarehouseCode(item.getDestinationFbaWarehouseCode());
moved.setRelabelApplicationId(application.getId()); moved.setRelabelNo(application.getRelabelNo());
moved.setRelabelStatus("PROCESSING"); moved.setStatus("RELABEL_PENDING");
moved.setSkuTypes(1); moved.setTotalQuantity(quantity);
moved.setCreatedAt(now); moved.setUpdatedAt(now);
if (returnCartons.insert(moved) != 1) throw conflict("新托盘库存创建失败");
ReturnInventoryCartonItem targetLine = new ReturnInventoryCartonItem();
targetLine.setId(IdWorker.getId()); targetLine.setCartonId(targetId);
targetLine.setCustomerId(source.getCustomerId()); targetLine.setSkuCode(sourceLine.getSkuCode());
targetLine.setProductNameZh(sourceLine.getProductNameZh()); targetLine.setQuantity(quantity);
if (returnCartonItems.insert(targetLine) != 1
|| returnBalances.adjustCartonQuantity(source.getCustomerId(), source.getOverseasWarehouseId(),
sourceLine.getSkuCode(), 1) != 1) throw conflict("退货库存拆分记账失败");
item.setReturnCartonId(targetId);
if (relabelItems.updateById(item) != 1) throw conflict("换标单来源托盘更新失败");
}
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
PalletOperationRequests.Swap request) {
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP",
@@ -610,6 +753,22 @@ public class PdaPalletOperationService {
relabelWorkHistory.insert(record);
}
private void recordReturnRelabelBinding(PdaWarehouseIdentity identity, RelabelApplication app,
List<RelabelItem> items, String targetPallet) {
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
if (task == null) throw conflict("退货换标作业任务不存在,不能记录组托");
var record = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory();
record.setId(IdWorker.getId()); record.setTaskId(task.getId()); record.setApplicationId(app.getId());
record.setCustomerId(app.getCustomerId()); record.setAction("PALLET_BIND");
record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus());
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
record.setCreatedAt(LocalDateTime.now());
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
"cartonMarks", items.stream().map(RelabelItem::getCartonMark).toList(),
"targetPallets", List.of(targetPallet))));
relabelWorkHistory.insert(record);
}
private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) {
if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus()))
throw conflict("当前换标状态不能组托");
@@ -78,6 +78,22 @@ public class PdaRelabelController {
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/return-skus/complete")
public ApiResponse<PdaRelabelResponses.Detail> completeReturnSku(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSku request) {
Identity identity = identity(user);
return ApiResponse.success(service.completeReturnSku(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@PostMapping("/{relabelNo}/return-skus/complete-total")
public ApiResponse<PdaRelabelResponses.Detail> completeReturnSkuTotal(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.BulkSkuTotal request) {
Identity identity = identity(user);
return ApiResponse.success(service.completeReturnSkuTotal(identity.operatorKey(), identity.operatorName(),
identity.overseasWarehouseId(), relabelNo, request));
}
@GetMapping("/{relabelNo}/documents")
public ApiResponse<List<PdaRelabelService.Document>> documents(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) {
@@ -37,6 +37,16 @@ public final class PdaRelabelRequests {
public record SkuVerification(@NotBlank String skuCode, @NotBlank String newSkuCode,
@Positive int quantity) { }
public record BulkSku(@NotNull @Positive Long itemId,
@Positive int quantity,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record BulkSkuTotal(@NotBlank @Size(max = 255) String skuCode,
@Positive int quantity,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Skip(@NotNull @Positive Long itemId,
@NotBlank @Size(max = 255) String oldLabel,
@NotBlank @Size(max = 1000) String reason,
@@ -32,7 +32,7 @@ public final class PdaRelabelResponses {
public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel,
String result, String exceptionCode, String reason,
String operatorName, LocalDateTime occurredAt) { }
int quantity, String operatorName, LocalDateTime occurredAt) { }
public record History(String action, String fromStatus, String toStatus,
String operatorName, String detail, LocalDateTime createdAt) { }
@@ -20,6 +20,7 @@ public class PdaRelabelUnitFact {
private String result;
private String exceptionCode;
private String reason;
private Integer quantity;
private String operatorKey;
private String operatorName;
private LocalDateTime occurredAt;
@@ -90,6 +90,17 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
}
}
@Override
public java.util.List<UploadedFile> list(String customerId, long taskId) {
MetadataListEnvelope response = client.get()
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", tenantId(customerId), BUSINESS_TYPE, taskId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Relabel photo list failed"); })
.body(MetadataListEnvelope.class);
if (response == null || !"200".equals(response.code()) || response.data() == null) return java.util.List.of();
return response.data().stream().map(value -> new UploadedFile(value.fileId(), value.fileName(), value.contentType(), value.sizeBytes() == null ? 0L : value.sizeBytes())).toList();
}
@Override
public DownloadedFile download(String customerId, long taskId, long fileId) {
validate(customerId, taskId, fileId);
@@ -130,6 +141,7 @@ public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway {
}
private record MetadataEnvelope(String code, String message, Metadata data) { }
private record MetadataListEnvelope(String code, String message, java.util.List<Metadata> data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -6,6 +6,7 @@ public interface RelabelPhotoFileGateway {
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
void validate(String customerId, long taskId, long fileId);
DownloadedFile download(String customerId, long taskId, long fileId);
java.util.List<UploadedFile> list(String customerId, long taskId);
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { }
record DownloadedFile(byte[] content, String fileName, String contentType) { }
@@ -39,6 +39,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
@@ -238,6 +239,88 @@ public class PdaRelabelService {
return result;
}
@Transactional
public PdaRelabelResponses.Detail completeReturnSku(String operatorKey, String operatorName, long warehouseId,
String relabelNo, PdaRelabelRequests.BulkSku request) {
UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU", request);
PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU",
request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, warehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
RelabelItem item = requireItem(application, request.itemId());
if (!"RETURN_BULK_SKU_LABEL".equals(operationType(item))) throw conflict("当前明细不是退货按件贴标任务");
int completed = quantity(factList(task).stream().filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED");
int remaining = value(item.getNotifiedPackageCount()) - completed;
if (request.quantity() > remaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量");
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
// A bulk SKU is shared by many units/pallets; old_label is uniquely indexed for physical carton scans.
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
fact.setResult("RELABELLED"); fact.setQuantity(request.quantity()); fact.setOperatorKey(operatorKey);
fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity());
updateTask(task, request.expectedVersion());
insertHistory(task, "RETURN_BULK_SKU", PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("factId", fact.getId(), "itemId", item.getId(), "skuCode", item.getSkuCode(), "quantity", request.quantity()));
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "RETURN_BULK_SKU", request.idempotencyKey(), command, result);
return result;
}
@Transactional
public PdaRelabelResponses.Detail completeReturnSkuTotal(String operatorKey, String operatorName, long warehouseId,
String relabelNo, PdaRelabelRequests.BulkSkuTotal request) {
UnitCommand command = new UnitCommand(relabelNo, "RETURN_BULK_SKU_TOTAL", request);
PdaRelabelResponses.Detail replay = replay(operatorKey, warehouseId, relabelNo, "RETURN_BULK_SKU_TOTAL",
request.idempotencyKey(), command);
if (replay != null) return replay;
RelabelApplication application = requireApplication(relabelNo, warehouseId, true);
PdaRelabelTask task = requireOwnedTask(application, operatorKey, true);
requireVersion(task, request.expectedVersion());
String skuCode = request.skuCode().trim();
List<RelabelItem> skuItems = itemList(application).stream()
.filter(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))
.filter(item -> skuCode.equalsIgnoreCase(Objects.toString(item.getSkuCode(), "").trim()))
.toList();
if (skuItems.isEmpty()) throw notFound("当前换标单没有该退货 SKU");
List<PdaRelabelUnitFact> existingFacts = factList(task);
Map<Long, Integer> completedByItem = new HashMap<>();
for (RelabelItem item : skuItems) {
completedByItem.put(item.getId(), quantity(existingFacts.stream()
.filter(fact -> item.getId().equals(fact.getItemId())).toList(), "RELABELLED"));
}
int totalRemaining = skuItems.stream().mapToInt(item -> value(item.getNotifiedPackageCount())
- completedByItem.getOrDefault(item.getId(), 0)).sum();
if (request.quantity() > totalRemaining) throw conflict("本次完成数量超过该 SKU 剩余贴标数量");
int remainingToAllocate = request.quantity();
List<Map<String, Object>> allocations = new ArrayList<>();
LocalDateTime occurredAt = LocalDateTime.now();
for (RelabelItem item : skuItems) {
if (remainingToAllocate == 0) break;
int itemRemaining = value(item.getNotifiedPackageCount()) - completedByItem.getOrDefault(item.getId(), 0);
int allocated = Math.min(remainingToAllocate, itemRemaining);
if (allocated <= 0) continue;
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
// Do not write a shared SKU into the unique physical old-label column.
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
fact.setResult("RELABELLED"); fact.setQuantity(allocated); fact.setOperatorKey(operatorKey);
fact.setOperatorName(operatorName); fact.setOccurredAt(occurredAt); unitFacts.insert(fact);
allocations.add(Map.of("itemId", item.getId(), "cartonMark", Objects.toString(item.getCartonMark(), ""),
"quantity", allocated, "factId", fact.getId()));
remainingToAllocate -= allocated;
}
task.setRelabelledPackages(value(task.getRelabelledPackages()) + request.quantity());
updateTask(task, request.expectedVersion());
insertHistory(task, "RETURN_BULK_SKU_TOTAL", PROCESSING, PROCESSING, operatorKey, operatorName,
Map.of("skuCode", skuCode, "quantity", request.quantity(), "allocations", allocations));
PdaRelabelResponses.Detail result = detail(application, task);
saveOperation(task, operatorKey, "RETURN_BULK_SKU_TOTAL", request.idempotencyKey(), command, result);
return result;
}
@Transactional(readOnly = true)
public List<Document> documents(long warehouseId, String relabelNo) {
RelabelApplication app = requireApplication(relabelNo, warehouseId, false);
@@ -311,26 +394,28 @@ public class PdaRelabelService {
if (!request.manualCompletion()) bindingGuard.requireBound(application);
List<RelabelItem> detailItems = itemList(application);
List<PdaRelabelUnitFact> facts = factList(task);
boolean incomplete = detailItems.stream().anyMatch(item -> facts.stream()
.filter(fact -> item.getId().equals(fact.getItemId())).count() < value(item.getNotifiedPackageCount()));
boolean incomplete = detailItems.stream().anyMatch(item -> processedQuantity(facts, item.getId()) < value(item.getNotifiedPackageCount()));
if ((incomplete || request.manualCompletion()) && !StringUtils.hasText(request.remark()))
throw conflict("手动完成必须填写仓库异常作业备注");
if (incomplete && !request.manualCompletion()) throw conflict("仍有未处理箱,请选择手动完成并填写作业备注");
if (request.photoFileIds() == null || request.photoFileIds().isEmpty() || request.photoFileIds().size() > 20) throw conflict("完成换标必须上传 1 至 20 张作业照片");
if (request.manualCompletion() && request.remark().trim().length() > 900) throw conflict("手动作业备注不能超过 900 字");
for (RelabelItem item : detailItems) {
long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count();
long actual = processedQuantity(facts, item.getId());
if (actual > value(item.getNotifiedPackageCount())) {
throw conflict("Every relabel item must have exactly one fact per notified package");
}
for (long i = actual; i < value(item.getNotifiedPackageCount()); i++) {
long missing = value(item.getNotifiedPackageCount()) - actual;
int factCount = "RETURN_BULK_SKU_LABEL".equals(operationType(item)) && missing > 0 ? 1 : Math.toIntExact(missing);
for (int i = 0; i < factCount; i++) {
PdaRelabelUnitFact fact = new PdaRelabelUnitFact();
fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId());
fact.setItemId(item.getId()); fact.setCustomerId(application.getCustomerId());
fact.setResult("EXCEPTION"); fact.setExceptionCode("MANUAL_COMPLETE");
fact.setQuantity("RETURN_BULK_SKU_LABEL".equals(operationType(item)) ? Math.toIntExact(missing) : 1);
fact.setReason(request.remark()); fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName);
fact.setOccurredAt(LocalDateTime.now()); unitFacts.insert(fact);
task.setExceptionPackages(value(task.getExceptionPackages()) + 1);
task.setExceptionPackages(value(task.getExceptionPackages()) + fact.getQuantity());
}
}
List<Long> photoIds = request.photoFileIds().stream().distinct().toList();
@@ -475,6 +560,7 @@ public class PdaRelabelService {
resolvedNewLabel = trim(item.getFbaCartonMark());
}
fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(resolvedNewLabel); fact.setResult(resultCode);
fact.setQuantity(1);
fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason));
fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now());
try { unitFacts.insert(fact); }
@@ -622,15 +708,15 @@ public class PdaRelabelService {
item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(),
item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(),
item.getCartonMark(), item.getFbaCartonMark(), operationType(item), readSkuItems(item.getSkuRelabelItemsJson()),
value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"),
count(itemFacts, "EXCEPTION"),
value(item.getNotifiedPackageCount()), quantity(itemFacts, "RELABELLED"), quantity(itemFacts, "SKIPPED"),
quantity(itemFacts, "EXCEPTION"),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "CARTON") ? 1 : 0),
count(itemFacts, "RELABELLED") + (itemFacts.isEmpty() && mixedStageDone(task, item.getId(), "SKU") ? 1 : 0),
item.getDestinationFbaWarehouseCode());
}).toList();
List<PdaRelabelResponses.UnitFact> factResponses = facts.stream().map(fact ->
new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(),
fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList();
fact.getResult(), fact.getExceptionCode(), fact.getReason(), factQuantity(fact), fact.getOperatorName(), fact.getOccurredAt())).toList();
List<PdaRelabelResponses.History> historyResponses = task == null ? List.of() : histories.selectList(
new LambdaQueryWrapper<PdaRelabelHistory>().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId())
.eq(PdaRelabelHistory::getTaskId, task.getId())
@@ -727,6 +813,9 @@ public class PdaRelabelService {
private String operationType(RelabelApplication application) {
List<RelabelItem> items = itemList(application);
if (!items.isEmpty() && items.stream().allMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
return "RETURN_BULK_SKU_LABEL";
}
boolean sku = items.stream().anyMatch(item -> Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
boolean carton = items.stream().anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
return sku && carton ? "CARTON_AND_SKU" : sku ? "SKU_LABEL" : "CARTON_LABEL";
@@ -748,6 +837,13 @@ public class PdaRelabelService {
private static int count(List<PdaRelabelUnitFact> facts, String result) {
return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count();
}
private static int quantity(List<PdaRelabelUnitFact> facts, String result) {
return facts.stream().filter(fact -> result.equals(fact.getResult())).mapToInt(PdaRelabelService::factQuantity).sum();
}
private static int processedQuantity(List<PdaRelabelUnitFact> facts, Long itemId) {
return facts.stream().filter(fact -> itemId.equals(fact.getItemId())).mapToInt(PdaRelabelService::factQuantity).sum();
}
private static int factQuantity(PdaRelabelUnitFact fact) { return fact.getQuantity() == null ? 1 : fact.getQuantity(); }
private static int value(Integer value) { return value == null ? 0 : value; }
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
private static String idempotencyKey(String value) { return value == null ? null : value.trim(); }
@@ -31,8 +31,10 @@ import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.entity.OutboundPdaTask;
@@ -70,10 +72,11 @@ import java.util.stream.Collectors;
@Service
public class RelabelApplicationService implements WalletResultHandler {
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String PENDING_PROCESSING = "PENDING_PROCESSING";
public static final String CUSTOMER_CONFIRM_PENDING = "CUSTOMER_CONFIRM_PENDING";
public static final String READY_FOR_OUTBOUND = "READY_FOR_OUTBOUND";
private static final Set<String> STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", CUSTOMER_CONFIRM_PENDING,
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "TRUCK_PICKUP", "EXPRESS_PICKUP", "PICKUP");
@@ -90,6 +93,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Resource private InventoryPositionMapper inventoryPositions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ReturnOrderMapper returnOrders;
@Resource private RelabelDocumentFileGateway documentFiles;
@Resource private PdaRelabelTaskMapper pdaTasks;
@Resource private RelabelPhotoFileGateway photoFiles;
@@ -203,7 +207,7 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!StringUtils.hasText(customerId)) throw badRequest("客户不能为空");
RelabelApplication application = applications.lockNo(customerId, relabelNo);
if (application == null) throw notFound("Relabel application not found");
if (!Set.of(PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料");
if (!Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING").contains(application.getStatus())) throw conflict("当前换标状态不允许修改资料");
return application;
}
@@ -270,9 +274,13 @@ public class RelabelApplicationService implements WalletResultHandler {
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();
}
result.add(new RelabelResponses.InventoryCandidate(null, null, carton.getSourceReturnNo(),
null, carton.getOverseasWarehouseId(), null, null, carton.getDestinationWarehouseCode(),
carton.getDestinationWarehouseName(), skuCodes, "退货合箱库存", 1, 0, List.of(),
carton.getDestinationWarehouseName(), skuCodes, "退货入库库存", availablePackages, 0, List.of(),
"RETURN_CARTON", carton.getId(), carton.getCartonMark(), cartonSkus));
}
return result;
@@ -347,6 +355,8 @@ public class RelabelApplicationService implements WalletResultHandler {
int notified = 0;
List<SourceInventory> sources = new java.util.ArrayList<>();
Set<String> selectedCartonMarks = new HashSet<>();
Map<Long, ReturnInventoryCarton> selectedReturnCartons = new LinkedHashMap<>();
Map<Long, Long> selectedReturnPackages = new LinkedHashMap<>();
for (RelabelRequests.Item item : request.items()) {
validateOperation(item);
if (item.notifiedPackageCount() > item.totalPackageCount()) {
@@ -358,7 +368,11 @@ public class RelabelApplicationService implements WalletResultHandler {
throw badRequest("The same carton mark cannot create more than one relabel task");
}
if (item.returnCartonId() != null) {
ReturnInventoryCarton carton = returnCartons.lockId(item.returnCartonId());
ReturnInventoryCarton carton = selectedReturnCartons.get(item.returnCartonId());
if (carton == null) {
carton = returnCartons.lockId(item.returnCartonId());
if (carton != null) selectedReturnCartons.put(item.returnCartonId(), carton);
}
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
throw conflict("所选退货箱不属于当前客户和操作仓");
@@ -366,8 +380,10 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
}
if (!carton.getCartonMark().equalsIgnoreCase(item.cartonMark().trim())) {
throw conflict("退货箱仓库唛头已变化,请重新查询");
long requestedPackages = selectedReturnPackages.merge(carton.getId(),
(long) item.notifiedPackageCount(), Math::addExact);
if (requestedPackages > value(carton.getTotalQuantity())) {
throw conflict("退货托盘 " + carton.getCartonMark() + " 可用箱数不足");
}
sources.add(new SourceInventory(item, null, carton));
continue;
@@ -438,9 +454,7 @@ public class RelabelApplicationService implements WalletResultHandler {
application.setUpdatedAt(now);
applications.insert(application);
for (SourceInventory source : sources) {
if (source.returnCarton() == null) continue;
ReturnInventoryCarton carton = source.returnCarton();
for (ReturnInventoryCarton carton : selectedReturnCartons.values()) {
carton.setRelabelApplicationId(applicationId); carton.setRelabelNo(relabelNo);
carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now);
returnCartons.updateById(carton);
@@ -530,6 +544,12 @@ public class RelabelApplicationService implements WalletResultHandler {
if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status");
if (next.equals(application.getStatus())) return detail(customerId, relabelNo);
validateTransition(application.getStatus(), next);
if (PENDING_REVIEW.equals(application.getStatus()) && PENDING_PROCESSING.equals(next)
&& itemList(application.getId()).stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(operationType(item)))) {
boolean hasCartonFile = StringUtils.hasText(application.getCartonTotalFileId());
boolean hasSkuFile = StringUtils.hasText(application.getSkuTotalFileId());
if (hasCartonFile == hasSkuFile) throw conflict("退货贴标审核前必须且只能上传一个箱唛总文件或 SKU 总文件");
}
if ("PROCESSING".equals(next)
&& !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) {
throw conflict("Relabel funds must be frozen before processing");
@@ -541,6 +561,10 @@ public class RelabelApplicationService implements WalletResultHandler {
applications.updateById(application);
List<LastlegOrder> linkedOrders = tailOrders(application);
if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing");
if (PENDING_PROCESSING.equals(next)) {
application.setFulfillmentStatus("WAIT_RELABEL");
applications.updateById(application);
}
if (CUSTOMER_CONFIRM_PENDING.equals(next)) {
application.setFulfillmentStatus("WAIT_CUSTOMER_CONFIRMATION");
applications.updateById(application);
@@ -586,6 +610,12 @@ public class RelabelApplicationService implements WalletResultHandler {
case "REJECTED", "CANCELLED" -> "CANCELLED";
default -> mainStatus;
};
// 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)) {
orderStatus = "INBOUND_COMPLETED";
}
order.setCurrentStatus(orderStatus);
order.setSubOrderStatus("RELABEL:" + next);
order.setRelabelStatus(businessRelabelStatus(next));
@@ -595,6 +625,15 @@ public class RelabelApplicationService implements WalletResultHandler {
insertOrderHistory(customerId, order.getId(), orderBefore, orderStatus, request.remark(), now);
}
}
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
.eq(ReturnOrder::getCustomerId, customerId)
.last("LIMIT 1"));
if (returnOrder != null) {
returnOrder.setRelabelStatus(next);
returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder);
}
insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now);
return detail(customerId, relabelNo);
}
@@ -689,7 +728,7 @@ public class RelabelApplicationService implements WalletResultHandler {
}
List<RelabelItem> relabelItems = itemList(application.getId());
boolean requiresOutbound = relabelItems.stream()
.anyMatch(item -> !"SKU_LABEL".equals(operationType(item)));
.anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item)));
reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED");
reviewTask.setReviewerKey(operatorId); reviewTask.setReviewerName("客户确认");
reviewTask.setReviewRemark(request.remark()); reviewTask.setReviewedAt(LocalDateTime.now());
@@ -721,6 +760,22 @@ public class RelabelApplicationService implements WalletResultHandler {
request.remark(), now);
}
}
ReturnOrder returnOrder = returnOrders.selectOne(new LambdaQueryWrapper<ReturnOrder>()
.eq(ReturnOrder::getRelabelApplicationId, application.getId())
.eq(ReturnOrder::getCustomerId, customerId)
.last("LIMIT 1"));
if (returnOrder != null) {
returnOrder.setRelabelStatus("COMPLETED");
returnOrder.setUpdatedAt(now);
returnOrders.updateById(returnOrder);
}
for (RelabelItem item : relabelItems) {
if (item.getReturnCartonId() == null) continue;
ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId());
if (carton == null || !Objects.equals(carton.getRelabelApplicationId(), application.getId())) continue;
carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now);
carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton);
}
insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId,
request.remark(), now);
// 仓储转运换标在客户确认后立即恢复为可排车,发车时再转为 OUTBOUNDED。
@@ -739,7 +794,8 @@ public class RelabelApplicationService implements WalletResultHandler {
transfer.setRelabelCompletedAt(now);
transfer.setUpdatedAt(now);
warehouseTransfers.updateById(transfer);
application.setFulfillmentStatus(transferStatus);
application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus)
? "WAIT_PRE_DISPATCH" : transferStatus);
applications.updateById(application);
// 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。
LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId());
@@ -1057,7 +1113,8 @@ public class RelabelApplicationService implements WalletResultHandler {
orderHistories.insert(value);
}
private static void validateTransition(String from, String to) {
boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
boolean allowed = PENDING_REVIEW.equals(from) && Set.of(PENDING_PROCESSING, "REJECTED", "CANCELLED").contains(to)
|| PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to)
|| "PROCESSING".equals(from) && Set.of(CUSTOMER_CONFIRM_PENDING, "REJECTED", "CANCELLED").contains(to)
|| CUSTOMER_CONFIRM_PENDING.equals(from) && Set.of(READY_FOR_OUTBOUND, "REJECTED", "CANCELLED").contains(to)
|| READY_FOR_OUTBOUND.equals(from) && Set.of("COMPLETED", "CANCELLED").contains(to);
@@ -1065,7 +1122,9 @@ public class RelabelApplicationService implements WalletResultHandler {
}
private static String businessRelabelStatus(String workflowStatus) {
if ("PROCESSING".equals(workflowStatus)) return "PROCESSING";
if (Set.of(PENDING_REVIEW, PENDING_PROCESSING, "PROCESSING", "REJECTED", "CANCELLED").contains(workflowStatus)) {
return workflowStatus;
}
return Set.of(CUSTOMER_CONFIRM_PENDING, READY_FOR_OUTBOUND, "COMPLETED").contains(workflowStatus)
? "COMPLETED" : PENDING_PROCESSING;
}
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -20,12 +21,14 @@ public class RelabelBindingGuard {
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private InventoryPositionMapper positions;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ObjectMapper objectMapper;
public void requireBound(RelabelApplication app) {
List<RelabelItem> cartons = items.selectList(new QueryWrapper<RelabelItem>()
.eq("application_id", app.getId()).eq("customer_id", app.getCustomerId()))
.stream().filter(item -> !"SKU_LABEL".equals(item.getOperationType())).toList();
.stream().filter(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU", "RETURN_BULK_SKU_LABEL")
.contains(item.getOperationType())).toList();
if (cartons.isEmpty()) return;
Map<String, Set<String>> bindings = new HashMap<>();
for (PdaRelabelHistory history : histories.selectList(new QueryWrapper<PdaRelabelHistory>()
@@ -41,6 +44,13 @@ public class RelabelBindingGuard {
} catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); }
}
for (RelabelItem item : cartons) {
if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId());
if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())
|| !"PROCESSING".equals(carton.getRelabelStatus()))
throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标");
continue;
}
var position = item.getSourceInventoryPositionId() == null ? null
: positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId());
if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo())
@@ -6,17 +6,28 @@ import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
@RestController
@RequestMapping("/api/pda/v1/last-leg/return-receipts")
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaReturnReceiptController {
@Resource private ReturnReceiptService service;
@Resource private PalletPhysicalGateway physicalPallets;
@Resource private ReturnInventoryCartonMapper inventoryCartons;
@Resource private ReturnInventoryCartonItemMapper inventoryCartonItems;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper returnOrders;
@Resource private RelabelPhotoFileGateway photoFiles;
@PostMapping("/start") public ApiResponse<PdaReturnReceiptResponses.Start> start(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Start request) {
return ApiResponse.success(service.start(PdaWarehouseIdentity.require(user), request));
@@ -30,4 +41,61 @@ public class PdaReturnReceiptController {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
return ApiResponse.success(service.confirm(identity, request));
}
@PostMapping("/{returnNo}/manual-complete") public ApiResponse<com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses.Detail> manualComplete(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo, @Valid @RequestBody PdaReturnReceiptRequests.ManualComplete request) {
return ApiResponse.success(service.manualComplete(PdaWarehouseIdentity.require(user), returnNo, request));
}
public record Pallet(String palletCode, long palletId, String status, String printableHtml) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
@PostMapping(value = "/{returnNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<Photo> uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo,
@RequestPart("file") MultipartFile file) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), returnNo);
if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
if (file == null || file.isEmpty()) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "请选择退货打托照片");
if (file.getSize() > 10L * 1024 * 1024) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "照片不能超过 10MB");
if (file.getContentType() == null || !file.getContentType().toLowerCase(java.util.Locale.ROOT).startsWith("image/")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "只能上传图片");
var uploaded = photoFiles.upload(order.getCustomerId(), order.getId(), user.userId(), file);
return ApiResponse.success(new Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()));
}
@GetMapping("/{returnNo}/photos/files/{fileId}/download")
public org.springframework.http.ResponseEntity<byte[]> downloadPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo, @PathVariable long fileId) {
var file = service.downloadPhoto(PdaWarehouseIdentity.require(user), returnNo, fileId);
return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(file.contentType()))
.header("Cache-Control", "no-store").body(file.content());
}
@PostMapping("/pallets") public ApiResponse<Pallet> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.Pallet request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
if (returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo()) == null) {
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
}
var pallet = physicalPallets.ensureRelabelPallet(identity.overseasWarehouseId(),
"RETURN:" + request.returnNo() + ":" + request.skuCode() + ":" + System.nanoTime());
String code = org.springframework.web.util.HtmlUtils.htmlEscape(pallet.palletCode());
String returnNo = org.springframework.web.util.HtmlUtils.htmlEscape(request.returnNo());
String sku = org.springframework.web.util.HtmlUtils.htmlEscape(request.skuCode());
String html = "<!doctype html><html><head><meta charset=\"UTF-8\"><title>" + code + "</title><style>@page{size:100mm 70mm;margin:4mm}body{font-family:Arial,sans-serif;text-align:center;margin:0}h1{font-size:24px;margin:7mm 0 3mm}.code{font-size:17px;font-weight:700;word-break:break-all}.ref{font-size:11px;margin-top:4mm}</style></head><body><h1>退货托盘标签</h1><div class=\"code\">" + code + "</div><div class=\"ref\">退货单:" + returnNo + "<br>SKU:" + sku + "</div></body></html>";
return ApiResponse.success(new Pallet(pallet.palletCode(), pallet.palletId(), pallet.status(), html));
}
@PostMapping("/pallets/check") public ApiResponse<PdaReturnReceiptResponses.PalletCheck> checkPallet(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PdaReturnReceiptRequests.CheckPallet request) {
PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user);
var order = returnOrders.findActiveNo(String.valueOf(identity.overseasWarehouseId()), request.returnNo().trim());
if (order == null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该待签收退货单");
var physical = physicalPallets.resolve(request.palletCode().trim());
if (physical == null || physical.warehouseId() != identity.overseasWarehouseId()) {
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "当前仓没有该托盘");
}
var inventory = inventoryCartons.listPalletInventory(String.valueOf(identity.overseasWarehouseId()), physical.palletCode());
if (inventory.isEmpty()) return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), true, java.util.List.of(), 0));
if (inventory.stream().anyMatch(carton -> !order.getReturnNo().equals(carton.getSourceReturnNo()))) {
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "该托盘已绑定其他退货单,不能用于当前退货单");
}
java.util.LinkedHashSet<String> skuCodes = new java.util.LinkedHashSet<>(); long quantity = 0;
for (var carton : inventory) for (var item : inventoryCartonItems.listCarton(carton.getId())) { skuCodes.add(item.getSkuCode()); quantity += item.getQuantity() == null ? 0 : item.getQuantity(); }
return ApiResponse.success(new PdaReturnReceiptResponses.PalletCheck(physical.palletCode(), false, java.util.List.copyOf(skuCodes), quantity));
}
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.returnreceipt.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/return-orders")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')")
public class PlatformReturnOrderController {
@Resource private ReturnReceiptService service;
@PostMapping("/{returnNo}/review")
public ApiResponse<ReturnOrderResponses.Detail> review(@RequestHeader("X-Customer-Id") String customerId,
@PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Review request) {
return ApiResponse.success(service.review(customerId, returnNo, request));
}
}
@@ -12,10 +12,13 @@ public final class PdaReturnReceiptRequests {
private PdaReturnReceiptRequests() { }
public record Start(@NotBlank @Size(max = 128) String returnNo) { }
public record SkuLookup(@NotBlank @Size(max = 128) String skuCode) { }
public record Pallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String skuCode) { }
public record CheckPallet(@NotBlank @Size(max = 128) String returnNo, @NotBlank @Size(max = 128) String palletCode) { }
public record ManualComplete(@NotBlank @Size(max = 500) String discrepancyReason) { }
public record Confirm(@NotBlank @Size(max = 128) String returnNo,
@Size(max = 128) String trackingNo,
@NotBlank @Size(max = 16) String startedBy,
@Size(max = 128) String cartonMark,
@NotBlank @Size(max = 128) String palletCode,
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@NotBlank @Size(max = 255) String idempotencyKey) { }
public record Item(@NotBlank @Size(max = 128) String skuCode,
@@ -5,13 +5,16 @@ import java.util.List;
public final class PdaReturnReceiptResponses {
private PdaReturnReceiptResponses() { }
public record Start(String returnNo, String status, List<Item> expectedItems) { }
public record Start(String returnNo, String status, List<Item> expectedItems, List<CompletedPallet> completedPallets, List<Photo> photos) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record CompletedPallet(String palletCode, String skuCode, long quantity, String receiptBatchNo) { }
public record PalletCheck(String palletCode, boolean empty, List<String> skuCodes, long quantity) { }
public record SkuCandidates(String skuCode, List<Candidate> candidates) { }
public record Candidate(String returnNo, String customerId, String customerReferenceNo, String status,
long skuExpectedQuantity, long skuReceivedQuantity, long orderExpectedQuantity,
long orderReceivedQuantity) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { }
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String cartonMark, String orderStatus,
public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String palletCode, String orderStatus,
boolean fullyMatched, long expectedQuantity, long receivedQuantity,
LocalDateTime signedAt) { }
}
@@ -28,8 +28,13 @@ public final class ReturnOrderRequests {
String trackingNo, String skuCode, String status) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
public record RelabelCreate(@NotEmpty @Size(max = 500) List<@Valid RelabelItem> items,
@NotBlank @Size(max = 16) String fileScope,
@NotBlank @Size(max = 128) String destinationFbaWarehouseId,
@NotBlank @Size(max = 128) String destinationFbaWarehouseCode,
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 128) String contactName, @Size(max = 64) String contactPhone,
@Size(max = 32) String deliveryMethod, @Size(max = 1000) String remark) {}
@Size(max = 1000) String cartonRemark) {}
public record RelabelItem(@NotBlank @Size(max = 128) String skuCode,
@Positive long quantity) {}
public record Review(@NotBlank String decision, @Size(max = 500) String remark) { }
}
@@ -8,14 +8,17 @@ 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, LocalDateTime createdAt) { }
String relabelNo, String relabelStatus, String discrepancyReason, LocalDateTime createdAt) { }
public record Item(String skuCode, String productNameZh, long expectedQuantity,
long receivedQuantity) { }
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts) { }
public record Receipt(String receiptNo, String receiptBatchNo, String cartonMark, String trackingNo, String startedBy,
public record Detail(Summary order, String remark, List<Item> items, List<Receipt> receipts, List<PalletDetail> pallets, List<Photo> photos) { }
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
public record PalletDetail(String palletCode, int skuTypes, long totalQuantity, List<PalletItem> items) { }
public record PalletItem(String skuCode, String productNameZh, long quantity) { }
public record Receipt(String receiptNo, String receiptBatchNo, String palletCode, String trackingNo, String startedBy,
long receivedQuantity, String operatorName, LocalDateTime signedAt) { }
public record RelabelCandidate(Long cartonId, String cartonMark, String sourceReturnNo, String status,
long totalQuantity, List<Item> items, String relabelStatus) { }
public record RelabelCreated(String relabelNo, Long relabelApplicationId, String returnNo,
List<String> cartonMarks, String status) { }
List<String> skuCodes, String status) { }
}
@@ -18,6 +18,7 @@ public class ReturnInventoryCarton {
private Long sourceReceiptId;
private String sourceBatchNo;
private String sourceReturnNo;
private String palletCode;
private String cartonMark;
private String fbaCartonMark;
private String destinationWarehouseCode;
@@ -28,6 +28,8 @@ public class ReturnOrder {
private Long expectedQuantity;
private Long receivedQuantity;
private String remark;
private String discrepancyReason;
private LocalDateTime manuallyCompletedAt;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -15,6 +15,7 @@ public class ReturnReceipt {
private String customerId;
private String receiptNo;
private String receiptBatchNo;
private String palletCode;
private String cartonMark;
private String trackingNo;
private String boundBy;
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
@@ -35,9 +36,25 @@ public interface ReturnInventoryCartonMapper extends BaseMapper<ReturnInventoryC
@Select("SELECT * FROM ll_return_inventory_carton WHERE id=#{id} FOR UPDATE")
ReturnInventoryCarton lockId(@Param("id") long id);
@Update("UPDATE ll_return_inventory_carton SET total_quantity=#{remaining},sku_types=1,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} " +
"AND status='RELABEL_PENDING' AND total_quantity>#{remaining}")
int releasePartial(@Param("id") long id, @Param("customerId") String customerId,
@Param("applicationId") long applicationId, @Param("remaining") long remaining);
@Select("SELECT * FROM ll_return_inventory_carton WHERE source_receipt_id=#{receiptId} LIMIT 1")
ReturnInventoryCarton findReceipt(@Param("receiptId") long receiptId);
@Select("SELECT c.* FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.customer_id=#{customerId} AND c.overseas_warehouse_id=#{warehouseId} AND c.pallet_code=#{palletCode} AND c.status='AVAILABLE' ORDER BY c.id LIMIT 1 FOR UPDATE")
ReturnInventoryCarton findPalletSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id LIMIT 1")
ReturnInventoryCarton findPalletSkuForCheck(@Param("customerId") String ignoredCustomerId, @Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE overseas_warehouse_id=#{warehouseId} AND pallet_code=#{palletCode} AND status='AVAILABLE' ORDER BY id")
List<ReturnInventoryCarton> listPalletInventory(@Param("warehouseId") String warehouseId, @Param("palletCode") String palletCode);
@Select("SELECT * FROM ll_return_inventory_carton WHERE customer_id=#{customerId} AND carton_mark=#{cartonMark} LIMIT 1")
ReturnInventoryCarton findCustomerMark(@Param("customerId") String customerId, @Param("cartonMark") String cartonMark);
@@ -3,6 +3,12 @@ package com.baiyue.lastleg.returnreceipt.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnReceiptItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> { }
public interface ReturnReceiptItemMapper extends BaseMapper<ReturnReceiptItem> {
@Select("SELECT * FROM ll_return_receipt_item WHERE receipt_id=#{receiptId} ORDER BY id")
List<ReturnReceiptItem> listReceipt(@Param("receiptId") long receiptId);
}
@@ -63,6 +63,9 @@ public class ReturnInventoryService {
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
String requestedMark, List<ReceiptLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("退货签收库存明细不能为空");
if (lines.size() != 1) throw conflict("一个签收批次只能签收一个 SKU");
String normalizedPalletCode = required(receipt.getPalletCode(), "托盘码不能为空");
required(lines.get(0).skuCode(), "SKU 不能为空");
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
@@ -73,6 +76,7 @@ public class ReturnInventoryService {
carton.setId(cartonId); carton.setCustomerId(order.getCustomerId()); carton.setOverseasWarehouseId(order.getOverseasWarehouseId());
carton.setSourceReceiptId(receipt.getId()); carton.setSourceBatchNo(receipt.getReceiptBatchNo());
carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark);
carton.setPalletCode(normalizedPalletCode);
carton.setStatus(AVAILABLE); carton.setSkuTypes(lines.size()); carton.setTotalQuantity(total); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (ReceiptLine line : lines) {
@@ -25,6 +25,7 @@ import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
@@ -55,6 +56,7 @@ import java.util.Set;
@Service
public class ReturnReceiptService {
public static final String PENDING_RECEIPT = "PENDING_RECEIPT";
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String COMPLETED = "COMPLETED";
@@ -71,6 +73,8 @@ public class ReturnReceiptService {
@Resource private ReturnInventoryCartonItemMapper cartonItems;
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway photoFiles;
@Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator;
@Transactional
public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) {
@@ -92,7 +96,7 @@ public class ReturnReceiptService {
order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_RECEIPT);
order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW);
order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L);
order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash);
order.setCreatedBy(text(operatorId)); order.setCreatedAt(now); order.setUpdatedAt(now);
@@ -121,8 +125,8 @@ public class ReturnReceiptService {
tail.setSourceRegion("CUSTOMER"); tail.setSourceInboundId(returnOrder.getReturnNo());
tail.setSourceOrderId(String.valueOf(returnOrder.getId()));
tail.setSourceOrderNo(returnOrder.getReturnNo()); tail.setOverseasWarehouseId(returnOrder.getOverseasWarehouseId());
tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_BINDING" : "RETURN_RELABEL:NOT_REQUIRED");
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "PENDING_BINDING" : "NOT_REQUIRED");
tail.setCurrentStatus(PENDING_REVIEW); tail.setSubOrderStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? "RETURN_RELABEL:PENDING_REVIEW" : "RETURN_RELABEL:NOT_REQUIRED");
tail.setRelabelStatus(Boolean.TRUE.equals(returnOrder.getNeedRelabel()) ? PENDING_REVIEW : "NOT_REQUIRED");
tail.setCustomerRemark(returnOrder.getRemark()); tail.setSourceEventId("RETURN_ORDER:" + returnOrder.getReturnNo());
tail.setSourceLoadingPlanId("RETURN_RELABEL"); tail.setCreatedAt(now); tail.setUpdatedAt(now);
tail.setSourcePayloadHash(returnOrder.getRequestHash());
@@ -132,6 +136,30 @@ public class ReturnReceiptService {
orders.updateById(returnOrder);
}
@Transactional
public ReturnOrderResponses.Detail updatePending(String customerId, String returnNo, ReturnOrderRequests.Create request) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货预报不存在");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以修改");
Set<String> skus = new LinkedHashSet<>(); long expected = 0;
for (ReturnOrderRequests.Item input : request.items()) { if (!skus.add(norm(input.skuCode()))) throw badRequest("同一退货单不能重复预报相同 SKU"); expected = Math.addExact(expected, input.expectedQuantity()); }
order.setOverseasWarehouseId(text(request.overseasWarehouseId())); order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone())); order.setCustomerReferenceNo(text(request.customerReferenceNo()));
order.setNeedRelabel(request.needRelabel()); order.setRemark(text(request.remark())); order.setExpectedSkuTypes(skus.size()); order.setExpectedQuantity(expected); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
orderItems.delete(new LambdaQueryWrapper<ReturnOrderItem>().eq(ReturnOrderItem::getReturnOrderId, order.getId()));
for (ReturnOrderRequests.Item input : request.items()) { ReturnOrderItem item = new ReturnOrderItem(); item.setId(IdWorker.getId()); item.setReturnOrderId(order.getId()); item.setCustomerId(customerId); item.setSkuCode(text(input.skuCode())); item.setProductNameZh(text(input.productNameZh())); item.setExpectedQuantity(input.expectedQuantity()); item.setReceivedQuantity(0L); item.setCreatedAt(LocalDateTime.now()); item.setUpdatedAt(LocalDateTime.now()); orderItems.insert(item); }
return detail(customerId, returnNo);
}
@Transactional
public ReturnOrderResponses.Detail cancelPending(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货预报不存在");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以取消");
order.setStatus("CANCELLED"); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
return detail(customerId, returnNo);
}
@Transactional(readOnly = true)
public PageResult<ReturnOrderResponses.Summary> page(String customerId, ReturnOrderRequests.Query query) {
LambdaQueryWrapper<ReturnOrder> w = new LambdaQueryWrapper<ReturnOrder>()
@@ -166,11 +194,56 @@ public class ReturnReceiptService {
public ReturnOrderResponses.Detail detail(String customerId, String returnNo) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
List<ReturnOrderResponses.Item> items = orderItems.listOrder(order.getId()).stream().map(this::item).toList();
List<ReturnOrderResponses.Receipt> receiptViews = receipts.listOrder(order.getId()).stream().map(receipt ->
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(), receipt.getTrackingNo(),
List<ReturnOrderItem> orderLines = orderItems.listOrder(order.getId());
List<ReturnOrderResponses.Item> items = orderLines.stream().map(this::item).toList();
List<ReturnReceipt> orderReceipts = receipts.listOrder(order.getId());
List<ReturnOrderResponses.Receipt> receiptViews = orderReceipts.stream().map(receipt ->
new ReturnOrderResponses.Receipt(receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getPalletCode(), receipt.getTrackingNo(),
receipt.getBoundBy(), value(receipt.getReceivedQuantity()), receipt.getOperatorName(), receipt.getSignedAt())).toList();
return new ReturnOrderResponses.Detail(summary(order), order.getRemark(), items, receiptViews);
Map<String, Map<String, ReturnOrderResponses.PalletItem>> palletItems = new LinkedHashMap<>();
Map<String, String> productNames = new LinkedHashMap<>();
for (ReturnOrderItem line : orderLines) productNames.put(line.getSkuCode(), line.getProductNameZh());
for (ReturnReceipt receipt : orderReceipts) {
if (text(receipt.getPalletCode()) == null) continue;
Map<String, ReturnOrderResponses.PalletItem> bySku = palletItems.computeIfAbsent(receipt.getPalletCode(), ignored -> new LinkedHashMap<>());
for (ReturnReceiptItem line : receiptItems.listReceipt(receipt.getId())) bySku.merge(line.getSkuCode(),
new ReturnOrderResponses.PalletItem(line.getSkuCode(), productNames.get(line.getSkuCode()), value(line.getReceivedQuantity())),
(left, right) -> new ReturnOrderResponses.PalletItem(left.skuCode(), text(left.productNameZh()) == null ? right.productNameZh() : left.productNameZh(), left.quantity() + right.quantity()));
}
List<ReturnOrderResponses.PalletDetail> palletViews = palletItems.entrySet().stream().map(entry -> {
List<ReturnOrderResponses.PalletItem> palletLines = List.copyOf(entry.getValue().values());
return new ReturnOrderResponses.PalletDetail(entry.getKey(), palletLines.size(), palletLines.stream().mapToLong(ReturnOrderResponses.PalletItem::quantity).sum(), palletLines);
}).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);
}
public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(String customerId, String returnNo, long fileId) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
return photoFiles.download(order.getCustomerId(), order.getId(), fileId);
}
@Transactional
public ReturnOrderResponses.Detail review(String customerId, String returnNo, ReturnOrderRequests.Review request) {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货预报不存在");
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("只有待审核退货预报可以审核");
String decision = norm(request.decision());
if (!Set.of("APPROVE", "REJECT").contains(decision)) throw badRequest("decision 必须为 APPROVE 或 REJECT");
if ("REJECT".equals(decision) && text(request.remark()) == null) throw badRequest("审核驳回必须填写原因");
order.setStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED");
if ("REJECT".equals(decision)) order.setRemark(text(request.remark()));
order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
if (tail != null) {
tail.setCurrentStatus("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED");
tail.setSubOrderStatus("RETURN_RELABEL:" + ("APPROVE".equals(decision) ? PENDING_RECEIPT : "REJECTED"));
tail.setRelabelStatus("APPROVE".equals(decision) && Boolean.TRUE.equals(order.getNeedRelabel()) ? "WAIT_INBOUND" : ("APPROVE".equals(decision) ? "NOT_REQUIRED" : "REJECTED"));
tail.setUpdatedAt(LocalDateTime.now()); lastlegOrders.updateById(tail);
}
return detail(customerId, returnNo);
}
@Transactional(readOnly = true)
@@ -195,44 +268,119 @@ public class ReturnReceiptService {
ReturnOrder order = orders.findNo(customerId, text(returnNo));
if (order == null) throw notFound("退货单不存在");
if (!COMPLETED.equals(order.getStatus())) throw conflict("退货单尚未完成签收");
String fileScope = norm(request.fileScope());
if (!Set.of("CARTON_TOTAL", "SKU_TOTAL").contains(fileScope)) throw badRequest("贴标总文件类型必须为 CARTON_TOTAL 或 SKU_TOTAL");
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.cartonMarks(), replay.getStatus());
List<ReturnInventoryCarton> selected = new ArrayList<>();
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("当前退货单已经生成贴标申请");
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));
Set<String> seen = new LinkedHashSet<>();
for (String mark : request.cartonMarks()) {
String normalized = text(mark);
if (!seen.add(normalized.toUpperCase(java.util.Locale.ROOT))) throw badRequest("箱唛不能重复");
ReturnInventoryCarton carton = cartons.findCustomerMark(customerId, normalized);
if (carton == null || !order.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw notFound("退货箱不存在: " + normalized);
if (!ReturnInventoryService.AVAILABLE.equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) throw conflict("退货箱不可申请换标: " + normalized);
if (carton.getSourceReturnNo() == null || !java.util.Arrays.asList(carton.getSourceReturnNo().split(",")).contains(order.getReturnNo())) throw conflict("退货箱不属于当前退货单: " + normalized);
selected.add(cartons.lockMark(normalized));
long totalQuantity = 0;
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
String sku = norm(requested.skuCode());
if (!seen.add(sku)) throw badRequest("同一贴标申请不能重复选择 SKU: " + requested.skuCode());
ReturnOrderItem received = receivedBySku.get(sku);
if (received == null || requested.quantity() > value(received.getReceivedQuantity())) {
throw conflict("SKU " + requested.skuCode() + " 的贴标数量超过当前退货单实收数量");
}
totalQuantity = Math.addExact(totalQuantity, requested.quantity());
}
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId);
LastlegOrder tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER"); tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("PENDING_ASSIGNMENT"); tail.setSubOrderStatus("RELABEL:RETURN_CARTON"); tail.setRelabelStatus("RETURN_CARTON"); tail.setPackageCount(selected.size());
tail.setProductQuantity(Math.toIntExact(selected.stream().mapToLong(c -> value(c.getTotalQuantity())).sum())); tail.setCreatedAt(now); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setContactName(text(request.contactName())); app.setContactPhone(text(request.contactPhone())); app.setDeliveryMethod(text(request.deliveryMethod()) == null ? "TRUCK" : text(request.deliveryMethod()));
app.setFulfillmentStatus("WAIT_RELABEL"); app.setRemark(text(request.remark())); app.setStatus("PENDING_PROCESSING"); app.setItemCount(selected.size()); app.setTotalPackageCount(selected.size()); app.setNotifiedPackageCount(selected.size()); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(hash(request)); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
for (ReturnInventoryCarton carton : selected) {
for (ReturnInventoryCartonItem ci : cartonItems.listCarton(carton.getId())) {
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId); item.setSkuCode(ci.getSkuCode()); item.setProductNameZh(ci.getProductNameZh()); item.setTotalPackageCount(1); item.setNotifiedPackageCount(1); item.setShipmentNo(order.getReturnNo()); item.setCartonMark(carton.getCartonMark()); item.setFbaCartonMark(carton.getFbaCartonMark()); item.setOperationType("CARTON_LABEL"); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setCreatedAt(now); relabelItems.insert(item);
}
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId());
if (tail == null) {
tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(numbers.next("RELABEL", order.getOverseasWarehouseId(), tail.getId()));
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER");
tail.setSourceInboundId("RETURN_RELABEL:" + relabelNo);
tail.setSourceEventId("RETURN_RELABEL:" + relabelNo);
tail.setSourceLoadingPlanId("RETURN_RELABEL");
tail.setSourcePayloadHash("sha256:" + requestHash);
tail.setSourceOrderId(String.valueOf(appId)); tail.setSourceOrderNo(relabelNo);
tail.setOverseasWarehouseId(order.getOverseasWarehouseId()); tail.setCurrentStatus("WAIT_RELABEL");
tail.setCreatedAt(now); tail.setPackageCount(Math.toIntExact(totalQuantity));
tail.setProductQuantity(Math.toIntExact(totalQuantity)); tail.setUpdatedAt(now); lastlegOrders.insert(tail);
}
order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_PROCESSING"); orders.updateById(order);
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(), selected.stream().map(ReturnInventoryCarton::getCartonMark).toList(), app.getStatus());
// A return relabel application belongs to the already received return tail-leg order.
// Do not create a second independent tail order or overwrite the inbound status.
tail.setRelabelOrderNo(relabelNo);
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));
tail.setUpdatedAt(now);
lastlegOrders.updateById(tail);
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId());
app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId());
app.setLabelDeadline(now.plusDays(7));
app.setContactName(text(order.getWarehouseContactName())); app.setContactPhone(text(order.getWarehouseContactPhone()));
app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app);
List<ReturnInventoryCarton> availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId());
Map<Long, ReturnInventoryCarton> selectedCartons = new LinkedHashMap<>();
for (ReturnOrderRequests.RelabelItem requested : request.items()) {
ReturnOrderItem source = receivedBySku.get(norm(requested.skuCode()));
long remaining = requested.quantity();
for (ReturnInventoryCarton candidate : availableCartons) {
if (remaining == 0) break;
if (!order.getReturnNo().equals(candidate.getSourceReturnNo())) continue;
long skuQuantity = cartonItems.listCarton(candidate.getId()).stream()
.filter(line -> norm(requested.skuCode()).equals(norm(line.getSkuCode())))
.mapToLong(line -> value(line.getQuantity())).sum();
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)
throw conflict("退货箱已被其他换标申请占用,请刷新后重试");
long allocated = Math.min(remaining, skuQuantity);
RelabelItem item = new RelabelItem(); item.setId(IdWorker.getId()); item.setApplicationId(appId); item.setCustomerId(customerId);
item.setSkuCode(source.getSkuCode());
item.setProductNameZh(text(source.getProductNameZh()) == null ? source.getSkuCode() : text(source.getProductNameZh()));
item.setTotalPackageCount(Math.toIntExact(allocated)); item.setNotifiedPackageCount(Math.toIntExact(allocated));
item.setShipmentNo(order.getReturnNo()); item.setLabelRequirement(text(request.cartonRemark()));
item.setDestinationWarehouseId(String.valueOf(destination.id())); item.setDestinationFbaWarehouseCode(destination.code());
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setReturnCartonId(carton.getId());
item.setCartonMark(carton.getCartonMark()); item.setSourceCartonMark(carton.getCartonMark());
item.setCreatedAt(now); relabelItems.insert(item);
remaining -= allocated;
}
if (remaining > 0) throw conflict("退货 SKU 可用实收数量不足: " + requested.skuCode());
}
for (ReturnInventoryCarton carton : selectedCartons.values()) {
carton.setRelabelApplicationId(appId); carton.setRelabelNo(relabelNo); carton.setRelabelStatus("PENDING");
carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); cartons.updateById(carton);
}
order.setRelabelApplicationId(appId); order.setRelabelNo(relabelNo); order.setRelabelStatus("PENDING_REVIEW"); orders.updateById(order);
return new ReturnOrderResponses.RelabelCreated(relabelNo, appId, order.getReturnNo(),
request.items().stream().map(ReturnOrderRequests.RelabelItem::skuCode).toList(), app.getStatus());
}
@Transactional(readOnly = true)
public PdaReturnReceiptResponses.Start start(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Start request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), request.returnNo());
List<PdaReturnReceiptResponses.CompletedPallet> completedPallets = receipts.listOrder(order.getId()).stream()
.filter(receipt -> text(receipt.getPalletCode()) != null)
.collect(java.util.stream.Collectors.toMap(ReturnReceipt::getPalletCode, receipt -> {
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
ReturnInventoryCartonItem item = carton == null ? null : cartonItems.listCarton(carton.getId()).stream().findFirst().orElse(null);
return new PdaReturnReceiptResponses.CompletedPallet(receipt.getPalletCode(), item == null ? "" : item.getSkuCode(),
item == null ? 0 : value(item.getQuantity()), receipt.getReceiptBatchNo());
}, (left, right) -> new PdaReturnReceiptResponses.CompletedPallet(left.palletCode(), left.skuCode(),
left.quantity() + right.quantity(), right.receiptBatchNo()), java.util.LinkedHashMap::new))
.values().stream().toList();
List<PdaReturnReceiptResponses.Photo> photos = photoList(order).stream()
.map(file -> new PdaReturnReceiptResponses.Photo(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes())).toList();
return new PdaReturnReceiptResponses.Start(order.getReturnNo(), order.getStatus(), orderItems.listOrder(order.getId()).stream()
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList());
.filter(item -> value(item.getReceivedQuantity()) < value(item.getExpectedQuantity())).map(this::pdaItem).toList(), completedPallets, photos);
}
@Transactional(readOnly = true)
public com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway.DownloadedFile downloadPhoto(PdaWarehouseIdentity identity, String returnNo, long fileId) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo);
return photoFiles.download(order.getCustomerId(), order.getId(), fileId);
}
@Transactional(readOnly = true)
@@ -255,6 +403,16 @@ public class ReturnReceiptService {
return new PdaReturnReceiptResponses.SkuCandidates(skuCode, candidates);
}
@Transactional
public ReturnOrderResponses.Detail manualComplete(PdaWarehouseIdentity identity, String returnNo, PdaReturnReceiptRequests.ManualComplete request) {
ReturnOrder order = activeOrder(String.valueOf(identity.overseasWarehouseId()), returnNo);
if (value(order.getReceivedQuantity()) >= value(order.getExpectedQuantity())) throw conflict("退货单已按预期数量签收完成,不需要手动完结");
order.setStatus("COMPLETED_WITH_DISCREPANCY"); order.setDiscrepancyReason(text(request.discrepancyReason()));
order.setManuallyCompletedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); orders.updateById(order);
syncReturnTail(order, "INBOUND_COMPLETED", true);
return detail(order.getCustomerId(), order.getReturnNo());
}
@Transactional
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
String key = text(request.idempotencyKey());
@@ -289,6 +447,7 @@ public class ReturnReceiptService {
receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId));
receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId));
receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED");
receipt.setPalletCode(text(request.palletCode()));
receipt.setReceivedQuantity(receiptQuantity); receipt.setOperatorId(identity.operatorKey());
receipt.setOperatorName(identity.operatorName()); receipt.setIdempotencyKey(key); receipt.setRequestHash(requestHash);
receipt.setSignedAt(now); receipt.setCreatedAt(now);
@@ -315,13 +474,20 @@ public class ReturnReceiptService {
left.skuCode(), left.productNameZh() == null ? right.productNameZh() : left.productNameZh(),
Math.addExact(left.quantity(), right.quantity())));
}
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, request.cartonMark(),
if (allocations.stream().map(allocation -> allocation.item().getSkuCode()).distinct().count() != 1) {
throw conflict("一个签收批次只能签收一个 SKU");
}
receipt.setCartonMark(inventory.createReceiptCarton(order, receipt, text(request.palletCode()),
new ArrayList<>(inventoryLines.values())).getCartonMark());
receipts.updateById(receipt);
List<ReturnOrderItem> finalItems = orderItems.listOrder(order.getId());
long received = finalItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum();
boolean completed = finalItems.stream().allMatch(item -> value(item.getReceivedQuantity()) == value(item.getExpectedQuantity()));
if (completed && photoFiles != null && photoList(order).isEmpty()) {
throw conflict("本次将完成签收,请先拍摄并上传退货打托照片");
}
order.setReceivedQuantity(received); order.setStatus(completed ? COMPLETED : PARTIALLY_RECEIVED); order.setUpdatedAt(now); orders.updateById(order);
syncReturnTail(order, completed ? "INBOUND_COMPLETED" : "PARTIALLY_RECEIVED", completed || received > 0);
ReturnReceiptBatch batch = new ReturnReceiptBatch();
batch.setId(IdWorker.getId()); batch.setReturnOrderId(order.getId()); batch.setReceiptId(receiptId);
batch.setCustomerId(order.getCustomerId()); batch.setBatchNo(receipt.getReceiptBatchNo());
@@ -330,6 +496,21 @@ public class ReturnReceiptService {
return confirmed(order, receipt, completed);
}
private void syncReturnTail(ReturnOrder order, String currentStatus, boolean inboundStarted) {
if (order.getLastlegOrderId() == null) return;
LastlegOrder tail = lastlegOrders.selectById(order.getLastlegOrderId());
if (tail == null) return;
tail.setCurrentStatus(currentStatus);
tail.setSubOrderStatus("RETURN_RELABEL:" + currentStatus);
tail.setRelabelStatus(Boolean.TRUE.equals(order.getNeedRelabel()) && inboundStarted ? "PENDING_PROCESSING" : "NOT_REQUIRED");
tail.setUpdatedAt(LocalDateTime.now());
lastlegOrders.updateById(tail);
}
private List<RelabelPhotoFileGateway.UploadedFile> photoList(ReturnOrder order) {
return photoFiles == null ? List.of() : photoFiles.list(order.getCustomerId(), order.getId());
}
private ReturnOrder activeOrder(String warehouseId, String returnNo) {
ReturnOrder order = orders.findActiveNo(warehouseId, text(returnNo));
if (order == null) throw notFound("当前仓没有待签收或签收中的退货单");
@@ -367,13 +548,13 @@ public class ReturnReceiptService {
return confirmed(order, receipt, COMPLETED.equals(order.getStatus()));
}
private PdaReturnReceiptResponses.Confirmed confirmed(ReturnOrder order, ReturnReceipt receipt, boolean completed) {
return new PdaReturnReceiptResponses.Confirmed(order.getReturnNo(), receipt.getReceiptNo(), receipt.getReceiptBatchNo(), receipt.getCartonMark(),
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(),
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.getCreatedAt()); }
value(order.getReceivedQuantity()), Boolean.TRUE.equals(order.getNeedRelabel()), order.getRelabelNo(), order.getRelabelStatus(), order.getDiscrepancyReason(), order.getCreatedAt()); }
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(),
@@ -33,7 +33,7 @@ public class FbaWarehouseValidator {
|| !"FBA".equals(warehouse.warehouseType())
|| !"ACTIVE".equals(warehouse.status())
|| !same(requestedCode, warehouse.warehouseCode())
|| !same(requestedName, warehouse.warehouseName())) {
|| !sameIfProvided(requestedName, warehouse.warehouseName())) {
throw invalid();
}
String region = firstRegion(response.data().addresses());
@@ -69,6 +69,10 @@ public class FbaWarehouseValidator {
&& requested.trim().equals(actual.trim());
}
private boolean sameIfProvided(String requested, String actual) {
return !StringUtils.hasText(requested) || same(requested, actual);
}
private String firstRegion(List<DomesticWarehouseDirectoryClient.Address> addresses) {
if (addresses == null) return null;
return addresses.stream().filter(Objects::nonNull)
@@ -478,6 +478,14 @@ public class WarehouseTransferService {
orderDetails.updateById(detail);
}
syncInboundPlanDestination(customerId, transfer);
if (StringUtils.hasText(transfer.getRelabelNo())) {
RelabelApplication relabel = relabelApplications.lockNo(customerId, transfer.getRelabelNo());
if (relabel != null && "WAIT_FBA_DESTINATION".equals(relabel.getFulfillmentStatus())) {
relabel.setFulfillmentStatus("WAIT_PRE_DISPATCH");
relabel.setUpdatedAt(now);
relabelApplications.updateById(relabel);
}
}
insertOrderHistory(customerId, order.getId(), order.getCurrentStatus(), order.getCurrentStatus(), transferNo,
"Warehouse transfer destination bound by " + trim(operatorId), now);
results.add(new WarehouseTransferResponses.DestinationResult(customerId, transferNo));
@@ -1160,7 +1168,7 @@ public class WarehouseTransferService {
throw conflict("Source inventory balance is missing for warehouse transfer receipt");
}
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
new InventoryCommandRequests.StockIn(customerId, null,
new InventoryCommandRequests.StockIn(customerId, sourceBalance.getCustomerName(),
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
@@ -1957,6 +1957,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION',
exception_code VARCHAR(32) NULL COMMENT '异常编码',
reason VARCHAR(1000) NULL COMMENT '跳过或异常原因',
quantity INT NOT NULL DEFAULT 1 COMMENT '本次事实处理件数,箱级作业固定为1',
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识',
operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照',
occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间',
@@ -1965,6 +1966,8 @@ CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact (
KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id),
KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id)
) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_pda_unit_fact' AND column_name='quantity'), 'SELECT 1', 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason'));
PREPARE ll_relabel_fact_quantity_stmt FROM @ddl; EXECUTE ll_relabel_fact_quantity_stmt; DEALLOCATE PREPARE ll_relabel_fact_quantity_stmt;
CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation (
id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID',
@@ -2616,6 +2619,8 @@ SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN trac
PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
EXECUTE ll_return_receipt_alter_stmt;
DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
SET @ll_return_receipt_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''本次签收绑定的托盘码'' AFTER receipt_batch_no'));
PREPARE ll_return_receipt_add_pallet_stmt FROM @ll_return_receipt_add_pallet; EXECUTE ll_return_receipt_add_pallet_stmt; DEALLOCATE PREPARE ll_return_receipt_add_pallet_stmt;
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
@@ -2631,15 +2636,17 @@ PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
new_carton_mark VARCHAR(255) NULL COMMENT '换标后新箱唛',
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
SET @ll_return_carton_add_pallet = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='pallet_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code VARCHAR(128) NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no'));
PREPARE ll_return_carton_add_pallet_stmt FROM @ll_return_carton_add_pallet; EXECUTE ll_return_carton_add_pallet_stmt; DEALLOCATE PREPARE ll_return_carton_add_pallet_stmt;
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔';
@@ -2679,3 +2686,8 @@ CREATE TABLE IF NOT EXISTS ll_container_loading_evidence (
UNIQUE KEY uk_ll_container_loading_evidence_type(source_container_booking_id,evidence_type),
UNIQUE KEY uk_ll_container_loading_evidence_file(source_container_booking_id,source_file_id)
) ENGINE=InnoDB COMMENT='海外拆柜只读国内装柜照片引用';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='discrepancy_reason'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason VARCHAR(500) NULL COMMENT ''手动完结差异原因'' AFTER remark'));
PREPARE ll_return_order_discrepancy_stmt FROM @ddl; EXECUTE ll_return_order_discrepancy_stmt; DEALLOCATE PREPARE ll_return_order_discrepancy_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='manually_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at DATETIME(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_reason'));
PREPARE ll_return_order_manual_completed_stmt FROM @ddl; EXECUTE ll_return_order_manual_completed_stmt; DEALLOCATE PREPARE ll_return_order_manual_completed_stmt;
@@ -66,6 +66,40 @@ class DeliveryCompletedOutboxServiceTest {
verifyNoInteractions(mapper);
}
@Test
void skipsWarehouseTransferWithoutDomesticSourceOrder() {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
ReflectionTestUtils.setField(service, "outbox", mapper);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(2L); delivery.setDeliveryNo("DL-WT"); delivery.setVersion(1L);
LastlegOrder transfer = new LastlegOrder();
transfer.setId(22L); transfer.setOrderType("WAREHOUSE_TRANSFER");
transfer.setCustomerId("88"); transfer.setSourceInboundId("WAREHOUSE_TRANSFER:WT-1");
service.publish(delivery, List.of(transfer), Instant.parse("2026-09-15T08:00:00Z"));
verifyNoInteractions(mapper);
}
@Test
void skipsOverseasLocalOrderWithoutDomesticSourceOrder() {
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
DeliveryCompletedOutboxService service = new DeliveryCompletedOutboxService();
ReflectionTestUtils.setField(service, "outbox", mapper);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().registerModule(new JavaTimeModule()));
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(3L); delivery.setDeliveryNo("DL-LOCAL"); delivery.setVersion(1L);
LastlegOrder local = new LastlegOrder();
local.setId(23L); local.setOrderType("RETURN_RELABEL"); local.setSourceRegion("CUSTOMER");
local.setCustomerId("88"); local.setSourceInboundId("RETURN-1");
service.publish(delivery, List.of(local), Instant.parse("2026-09-15T08:00:00Z"));
verifyNoInteractions(mapper);
}
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.palletops.controller;
import com.baiyue.lastleg.palletops.dto.PalletOperationResponses;
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -8,6 +10,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
@@ -35,4 +38,21 @@ class PdaPalletOperationControllerContractTest {
assertFalse(sql.contains("&lt;"));
assertFalse(sql.contains("&gt;"));
}
@Test void relabelSourceAndResolvedPalletSerializeSnowflakeIdsAsStrings() throws Exception {
ObjectMapper mapper = new ObjectMapper();
var source = new PalletOperationResponses.RelabelSource(2100204001227157507L, 2L,
2100203890682015746L, "PLT-1", "LOC-1", "IN-1", 1L, 1L, List.of("C-1"));
var content = new PalletOperationResponses.PalletContent(2100203890682015746L, "PLT-1",
"OCCUPIED", 1, 2092783373163335682L, "LOC-1", null, 1L, List.of());
assertEquals("2100204001227157507", mapper.readTree(mapper.writeValueAsString(source))
.path("positionId").textValue());
assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(source))
.path("palletId").textValue());
assertEquals("2100203890682015746", mapper.readTree(mapper.writeValueAsString(content))
.path("palletId").textValue());
assertEquals("2092783373163335682", mapper.readTree(mapper.writeValueAsString(content))
.path("locationId").textValue());
}
}
@@ -9,6 +9,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@@ -37,4 +38,23 @@ class HttpPalletPhysicalGatewayTest {
.hasMessageContaining("404");
server.verify();
}
@Test
void resolveIgnoresNewWarehousePalletMetadata() {
server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1"))
.andRespond(withSuccess("""
{"code":"200","message":"success","data":{"palletId":"30","palletCode":"PAL-1",
"warehouseId":"88","locationId":"40","status":"IDLE","fbaWarehouseCode":"XLX1",
"unitPrice":1.00,"version":2,"locationType":"VIRTUAL","locationStatus":"ACTIVE",
"reservable":true,"reservationBlockReason":null},
"timestamp":"2026-09-19T03:18:35.283336500Z"}
""", MediaType.APPLICATION_JSON));
var result = gateway.resolve("PAL-1");
assertThat(result.palletId()).isEqualTo(30L);
assertThat(result.palletCode()).isEqualTo("PAL-1");
assertThat(result.fbaWarehouseCode()).isEqualTo("XLX1");
server.verify();
}
}
@@ -18,6 +18,11 @@ import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.service.RelabelPalletMutationService;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryBalanceMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -62,6 +67,9 @@ class PdaPalletOperationServiceTest {
@Mock private RelabelApplicationMapper relabelApplications;
@Mock private RelabelItemMapper relabelItems;
@Mock private RelabelPalletMutationService relabelPalletMutations;
@Mock private ReturnInventoryCartonMapper returnCartons;
@Mock private ReturnInventoryCartonItemMapper returnCartonItems;
@Mock private ReturnInventoryBalanceMapper returnBalances;
private PdaPalletOperationService service;
@BeforeEach void setUp() {
@@ -75,10 +83,44 @@ class PdaPalletOperationServiceTest {
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "relabelPalletMutations", relabelPalletMutations);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "returnCartonItems", returnCartonItems);
ReflectionTestUtils.setField(service, "returnBalances", returnBalances);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
service.configureTransactions(new NoOpTransactionManager());
}
@Test void partialReturnRelabelKeepsUnlabelledRemainderOnOriginalPallet() {
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1");
RelabelItem item = new RelabelItem(); item.setId(701L); item.setSkuCode("SKU-1");
item.setReturnCartonId(801L); item.setDestinationFbaWarehouseCode("XLX6");
ReturnInventoryCarton original = new ReturnInventoryCarton(); original.setId(801L);
original.setCustomerId("100"); original.setOverseasWarehouseId("88");
original.setTotalQuantity(100L); original.setPalletCode("OLD");
ReturnInventoryCartonItem originalLine = new ReturnInventoryCartonItem();
originalLine.setId(901L); originalLine.setSkuCode("SKU-1"); originalLine.setQuantity(100L);
when(relabelItems.selectList(any())).thenReturn(List.of(item));
when(returnCartonItems.lockCarton(801L)).thenReturn(List.of(originalLine));
when(returnCartonItems.updateById(any(ReturnInventoryCartonItem.class))).thenReturn(1);
when(returnCartons.releasePartial(801L, "100", 600L, 70L)).thenReturn(1);
when(returnCartons.insert(any(ReturnInventoryCarton.class))).thenReturn(1);
when(returnCartonItems.insert(any(ReturnInventoryCartonItem.class))).thenReturn(1);
when(returnBalances.adjustCartonQuantity("100", "88", "SKU-1", 1)).thenReturn(1);
when(relabelItems.updateById(any(RelabelItem.class))).thenReturn(1);
ReflectionTestUtils.invokeMethod(service, "splitReturnRelabelCarton", app, item, original,
pallet(30L, "NEW-XLX6", 88L, 0), 30L);
assertEquals(70L, originalLine.getQuantity());
var moved = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(returnCartons).insert(moved.capture());
assertEquals(30L, moved.getValue().getTotalQuantity());
assertEquals("NEW-XLX6", moved.getValue().getPalletCode());
assertEquals("XLX6", moved.getValue().getDestinationWarehouseCode());
assertEquals(moved.getValue().getId(), item.getReturnCartonId());
verify(returnBalances).adjustCartonQuantity("100", "88", "SKU-1", 1);
}
@Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() {
when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1));
assertThrows(ResponseStatusException.class, () -> service.resolve(identity(),
@@ -100,7 +142,7 @@ class PdaPalletOperationServiceTest {
RelabelApplication application = new RelabelApplication(); application.setRelabelNo("RL-1");
application.setStatus("READY_FOR_OUTBOUND"); application.setOverseasWarehouseId("88");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(application);
when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1")).thenReturn(
when(physical.ensureRelabelPallet(88L, "RL-1:new-pallet-1", null)).thenReturn(
new PalletPhysicalGateway.PalletReference(30L, "PAL-NEW-1", 88L, 1030L, "IDLE", null, 0));
var result = service.createRelabelPallet(identity(),
@@ -358,6 +400,27 @@ class PdaPalletOperationServiceTest {
assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app));
}
@Test void relabelSourcesExposeAuthoritativePalletId() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100");
app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND");
app.setFulfillmentStatus("WAIT_PRE_DISPATCH");
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
RelabelItem item = relabelItem(701L, "CARTON-A", 100L);
when(relabelItems.selectList(any())).thenReturn(List.of(item));
when(positions.selectById(100L)).thenReturn(position(100L, 20L, "DISPLAY-CODE", 1L, 3L));
PalletInventoryRow source = row(100L, 20L, 3L, 0L); source.setPalletNo("CANONICAL-CODE");
source.setHoldPackages(1L);
when(mapper.relabelSources("100", "88", 200L)).thenReturn(List.of(source));
var result = service.relabelSources(identity(), new PalletOperationRequests.RelabelSources("RL-1"));
assertEquals(1, result.size());
assertEquals(20L, result.get(0).palletId());
assertEquals("CANONICAL-CODE", result.get(0).palletCode());
}
@Test void createsPalletBeforeCustomerConfirmationOnlyForClaimingOperator() {
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks);
@@ -366,7 +429,7 @@ class PdaPalletOperationServiceTest {
when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setStatus("PROCESSING"); task.setOperatorKey("7");
when(tasks.find("100", 600L)).thenReturn(task);
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm")).thenReturn(pallet(30L, "NEW", 88L, 0));
when(physical.ensureRelabelPallet(88L, "RL-1:before-confirm", null)).thenReturn(pallet(30L, "NEW", 88L, 0));
assertEquals("NEW", service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "before-confirm")).palletCode());
task.setOperatorKey("other");
assertThrows(ResponseStatusException.class, () -> service.createRelabelPallet(identity(), new PalletOperationRequests.NewRelabelPallet("RL-1", "different")));
@@ -117,4 +117,20 @@ class RelabelContractTest {
assertTrue(service.contains("requiresOutbound && (!StringUtils.hasText(deliveryMethod)"
+ " || !DELIVERY_METHODS.contains(deliveryMethod))"));
}
@Test
void returnRelabelReviewStatusIsProjectedWithoutLosingPendingOrRejectedState() throws Exception {
String service = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java"),
StandardCharsets.UTF_8);
assertTrue(service.contains("Set.of(PENDING_REVIEW, PENDING_PROCESSING, \"PROCESSING\", \"REJECTED\", \"CANCELLED\")"));
assertTrue(service.contains("return workflowStatus;"));
}
@Test
void returnCartonConstraintsAllowRelabelReservationWithPositiveInventory() throws Exception {
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
.replaceAll("\\s+", "").toUpperCase();
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED')ANDSKU_TYPES>0ANDTOTAL_QUANTITY>0"));
assertTrue(baseline.contains("STATUSIN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED')"));
}
}
@@ -264,6 +264,73 @@ class PdaRelabelServiceTest {
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
}
@Test
void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() {
application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100);
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100");
item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A");
RelabelItem second = item(); second.setId(11L); second.setOperationType("RETURN_BULK_SKU_LABEL");
second.setSkuCode("sku-fba100"); second.setNotifiedPackageCount(50); second.setCartonMark("PALLET-B");
PdaRelabelTask task = task("PROCESSING", 0); task.setExpectedPackages(100);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectList(any())).thenReturn(List.of(item, second));
var result = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1"));
assertEquals(100, result.relabelledPackages());
assertEquals(2, persistedFacts.size());
assertEquals(List.of(50, 50), persistedFacts.stream().map(PdaRelabelUnitFact::getQuantity).toList());
assertEquals(List.of(10L, 11L), persistedFacts.stream().map(PdaRelabelUnitFact::getItemId).toList());
assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null));
assertEquals(1, result.version());
}
@Test
void multiSkuPartialReturnRelabelRequiresBothSkuTotalsBeforeCompletion() {
verifyMultiSkuReturnCompletion(30, 15);
}
@Test
void multiSkuFullReturnRelabelRequiresBothSkuTotalsBeforeCompletion() {
verifyMultiSkuReturnCompletion(50, 40);
}
private void verifyMultiSkuReturnCompletion(int firstQuantity, int secondQuantity) {
application.setStatus("PROCESSING");
application.setNotifiedPackageCount(firstQuantity + secondQuantity);
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("SKU-A");
item.setNotifiedPackageCount(firstQuantity); item.setCartonMark("RC-A");
RelabelItem second = item(); second.setId(11L);
second.setOperationType("RETURN_BULK_SKU_LABEL"); second.setSkuCode("SKU-B");
second.setNotifiedPackageCount(secondQuantity); second.setCartonMark("RC-B");
PdaRelabelTask task = task("PROCESSING", 0);
task.setExpectedPackages(firstQuantity + secondQuantity);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectList(any())).thenReturn(List.of(item, second));
var first = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("SKU-A", firstQuantity, 0, "multi-a"));
assertEquals(firstQuantity, first.relabelledPackages());
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "multi-early", null)));
assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("SKU-A", 1, 1, "multi-over")));
var both = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("SKU-B", secondQuantity, 1, "multi-b"));
assertEquals(firstQuantity + secondQuantity, both.relabelledPackages());
assertEquals(List.of("SKU-A", "SKU-B"), persistedFacts.stream()
.map(fact -> fact.getItemId().equals(item.getId()) ? "SKU-A" : "SKU-B").toList());
assertEquals(List.of(firstQuantity, secondQuantity), persistedFacts.stream()
.map(PdaRelabelUnitFact::getQuantity).toList());
var completed = service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 2, "multi-complete", "multi SKU complete"));
assertEquals(PdaRelabelService.WAIT_REVIEW, completed.taskStatus());
}
@Test
void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() {
application.setStatus("PROCESSING");
@@ -30,6 +30,8 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -75,6 +77,7 @@ class RelabelApplicationServiceTest {
@Mock OutboundPdaTaskMapper outboundTasks;
@Mock WarehouseTransferOrderMapper warehouseTransfers;
@Mock OverseasFeeSourceService feeSources;
@Mock ReturnOrderMapper returnOrders;
@Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks;
@Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator();
@@ -103,6 +106,7 @@ class RelabelApplicationServiceTest {
ReflectionTestUtils.setField(service, "outboundItems", outboundItems);
ReflectionTestUtils.setField(service, "outboundTasks", outboundTasks);
ReflectionTestUtils.setField(service, "warehouseTransfers", warehouseTransfers);
ReflectionTestUtils.setField(service, "returnOrders", returnOrders);
ReflectionTestUtils.setField(service, "objectMapper", mapper());
lenient().when(walletCommands.publish(any(), any(Long.class), any(), any(), any(), any(), any(Long.class))).thenReturn("wallet-event-1");
lenient().when(inventoryPositions.findTenantPosition(any(), any())).thenAnswer(invocation -> {
@@ -411,6 +415,10 @@ class RelabelApplicationServiceTest {
when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 1, 1)));
when(histories.selectList(any())).thenReturn(List.of());
when(orders.selectById(21L)).thenReturn(order);
ReturnOrder returnOrder = new ReturnOrder();
returnOrder.setId(41L); returnOrder.setCustomerId("10001");
returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING");
when(returnOrders.selectOne(any())).thenReturn(returnOrder);
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
@@ -419,6 +427,8 @@ class RelabelApplicationServiceTest {
assertEquals("WAIT_CUSTOMER_CONFIRMATION", application.getFulfillmentStatus());
assertEquals("COMPLETED", order.getRelabelStatus());
assertEquals("RELABEL:CUSTOMER_CONFIRM_PENDING", order.getSubOrderStatus());
assertEquals(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, returnOrder.getRelabelStatus());
verify(returnOrders).updateById(returnOrder);
}
@Test
@@ -558,6 +568,7 @@ class RelabelApplicationServiceTest {
new RelabelRequests.CustomerConfirm("confirm-transfer", "accepted"));
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
assertNotNull(transfer.getRelabelCompletedAt());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
assertEquals("COMPLETED", transferOrder.getRelabelStatus());
@@ -610,6 +621,7 @@ class RelabelApplicationServiceTest {
assertEquals("ONT8", transfer.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transfer.getStatus());
assertEquals("WAIT_PRE_DISPATCH", application.getFulfillmentStatus());
assertEquals("ONT8", transferOrder.getFbaWarehouseCode());
assertEquals("READY_FOR_DISPATCH", transferOrder.getCurrentStatus());
}
@@ -45,6 +45,7 @@ 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("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
assertEquals(12, result.get(0).skuItems().get(0).quantity());
}
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.returnreceipt.dto.PdaReturnReceiptRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder;
import com.baiyue.lastleg.returnreceipt.entity.ReturnOrderItem;
@@ -14,6 +15,14 @@ import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptBatchMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptItemMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.entity.RelabelItem;
import com.baiyue.lastleg.service.FbaWarehouseValidator;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
@@ -53,6 +62,11 @@ class ReturnReceiptServiceTest {
@Mock ReturnInventoryService inventory;
@Mock LastlegOrderNoGenerator numbers;
@Mock LastlegOrderMapper lastlegOrders;
@Mock ReturnInventoryCartonMapper cartons;
@Mock ReturnInventoryCartonItemMapper cartonItems;
@Mock RelabelApplicationMapper relabelApplications;
@Mock RelabelItemMapper relabelItems;
@Mock FbaWarehouseValidator fbaWarehouseValidator;
ReturnReceiptService service;
PdaWarehouseIdentity identity;
@@ -69,9 +83,76 @@ class ReturnReceiptServiceTest {
ReflectionTestUtils.setField(service, "numbers", numbers);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "cartonItems", cartonItems);
ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications);
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouseValidator);
identity = new PdaWarehouseIdentity("operator-1", "仓管员", 10L);
}
@Test
void multiSkuPartialRelabelLocksOnlyRequestedQuantities() {
verifyMultiSkuRelabel(30, 15);
}
@Test
void multiSkuFullRelabelLocksAllReceivedQuantities() {
verifyMultiSkuRelabel(50, 40);
}
private void verifyMultiSkuRelabel(long firstQuantity, long secondQuantity) {
ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 90, 90);
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(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"));
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
when(cartons.lockId(101L)).thenReturn(first);
when(cartons.lockId(102L)).thenReturn(second);
when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50)));
when(cartonItems.listCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40)));
var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI",
new ReturnOrderRequests.RelabelCreate(List.of(
new ReturnOrderRequests.RelabelItem("SKU-A", firstQuantity),
new ReturnOrderRequests.RelabelItem("SKU-B", secondQuantity)),
"SKU_TOTAL", "200", "XLX6", "multi-key", "multi SKU acceptance"));
assertEquals("RL-MULTI", result.relabelNo());
var application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(relabelApplications).insert(application.capture());
assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue());
assertEquals(2, application.getValue().getItemCount());
var lines = ArgumentCaptor.forClass(RelabelItem.class);
verify(relabelItems, times(2)).insert(lines.capture());
assertEquals(List.of("SKU-A", "SKU-B"), lines.getAllValues().stream().map(RelabelItem::getSkuCode).toList());
assertEquals(List.of((int) firstQuantity, (int) secondQuantity),
lines.getAllValues().stream().map(RelabelItem::getNotifiedPackageCount).toList());
assertTrue(lines.getAllValues().stream().allMatch(line -> "RETURN_BULK_SKU_LABEL".equals(line.getOperationType())));
assertEquals("RELABEL_PENDING", first.getStatus());
assertEquals("RELABEL_PENDING", second.getStatus());
}
private static ReturnInventoryCarton returnCarton(long id, String mark, String pallet, long quantity) {
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setId(id); carton.setCustomerId("customer-1"); carton.setOverseasWarehouseId("10");
carton.setSourceReturnNo("RT-MULTI"); carton.setCartonMark(mark); carton.setPalletCode(pallet);
carton.setTotalQuantity(quantity); carton.setStatus("AVAILABLE");
return carton;
}
private static ReturnInventoryCartonItem returnCartonItem(String sku, long quantity) {
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
line.setSkuCode(sku); line.setQuantity(quantity); return line;
}
@Test
void startReturnsOnlyRemainingSkuLinesForTheBoundWarehouse() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 8, 3);
@@ -110,6 +191,26 @@ class ReturnReceiptServiceTest {
assertEquals("RETU-1", result.order().lastlegOrderNo());
}
@Test
void rejectingForecastMarksReturnAndLinkedTailAsRejected() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_REVIEW, 2, 0);
order.setLastlegOrderId(99L);
LastlegOrder tail = new LastlegOrder();
tail.setId(99L);
when(orders.findNo("customer-1", "RT-1")).thenReturn(order);
when(lastlegOrders.selectById(99L)).thenReturn(tail);
service.review("customer-1", "RT-1", new ReturnOrderRequests.Review("REJECT", "资料不齐"));
assertEquals("REJECTED", order.getStatus());
assertEquals("资料不齐", order.getRemark());
assertEquals("REJECTED", tail.getCurrentStatus());
assertEquals("RETURN_RELABEL:REJECTED", tail.getSubOrderStatus());
assertEquals("REJECTED", tail.getRelabelStatus());
verify(orders).updateById(order);
verify(lastlegOrders).updateById(tail);
}
@Test
void skuLookupReturnsOnlyActiveCandidatesFromTheBoundWarehouse() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 5, 1);
@@ -162,7 +263,7 @@ class ReturnReceiptServiceTest {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PARTIALLY_RECEIVED, 2, 1);
ReturnOrderItem line = item(11L, "SKU-A", 2, 1);
stubNewReceipt(order, List.of(line), "RC-AUTO");
var request = confirm("SKU", null, null, "key-final",
var request = confirm("SKU", null, "RC-AUTO", "key-final",
List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
var first = service.confirm(identity, request);
@@ -175,7 +276,7 @@ class ReturnReceiptServiceTest {
assertTrue(first.fullyMatched());
assertEquals(ReturnReceiptService.COMPLETED, first.orderStatus());
assertEquals(first.receiptNo(), replay.receiptNo());
assertEquals("RC-AUTO", replay.cartonMark());
assertEquals("RC-AUTO", replay.palletCode());
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
verify(batches, times(1)).insert(any(ReturnReceiptBatch.class));
verify(receipts, times(1)).insert(any(ReturnReceipt.class));
@@ -249,7 +350,7 @@ class ReturnReceiptServiceTest {
var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1);
ReturnReceipt committed = new ReturnReceipt();
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setCartonMark("RC-COMMITTED");
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setPalletCode("RC-COMMITTED");
committed.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(new ObjectMapper().writeValueAsBytes(request))));
when(orders.lockNo("RT-1")).thenReturn(completed);
@@ -301,8 +402,8 @@ class ReturnReceiptServiceTest {
return item;
}
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String cartonMark,
private static PdaReturnReceiptRequests.Confirm confirm(String startedBy, String trackingNo, String palletCode,
String key, List<PdaReturnReceiptRequests.Item> items) {
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, cartonMark, items, key);
return new PdaReturnReceiptRequests.Confirm("RT-1", trackingNo, startedBy, palletCode, items, key);
}
}
@@ -46,6 +46,18 @@ class FbaWarehouseValidatorTest {
assertThat(result.region()).isEqualTo("CA");
}
@Test
void acceptsMatchingIdAndCodeWhenCallerDoesNotCarryWarehouseName() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
.thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "XLX1", "XLX1")));
var result = validator.required("193000000000000001", "XLX1", null);
assertThat(result.id()).isEqualTo(193000000000000001L);
assertThat(result.code()).isEqualTo("XLX1");
assertThat(result.name()).isEqualTo("XLX1");
}
@Test
void rejectsAnActualOverseasReceivingWarehouseAsFbaDestination() {
when(client.activeWarehouse(193000000000000001L, "internal-token"))
@@ -352,6 +352,42 @@ class WarehouseTransferServiceTest {
assertEquals("WAREHOUSE_PICKUP", transfer.getValue().getDeliveryMethod());
}
@Test
void bindingDestinationReleasesLinkedRelabelForPreDispatch() {
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.WAIT_FBA_DESTINATION);
transfer.setNeedRelabel(true);
transfer.setRelabelNo("FBR-1");
transfer.setFbaWarehouseCode(null);
LastlegOrder order = tailOrder();
RelabelApplication relabel = new RelabelApplication();
relabel.setId(71L);
relabel.setCustomerId("TENANT-A");
relabel.setRelabelNo("FBR-1");
relabel.setFulfillmentStatus("WAIT_FBA_DESTINATION");
InboundPlan plan = new InboundPlan();
plan.setId(81L);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(transfer.getLastlegOrderId())).thenReturn(order);
when(orderDetails.selectList(any())).thenReturn(List.of());
when(inboundPlans.findBySourceOrder("TENANT-A", transfer.getLastlegOrderId())).thenReturn(plan);
when(inboundPlanLines.selectList(any())).thenReturn(List.of());
when(inboundPlanPallets.selectList(any())).thenReturn(List.of());
when(relabelApplications.lockNo("TENANT-A", "FBR-1")).thenReturn(relabel);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
when(transferPallets.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of());
service.bindDestination("OP-1", new WarehouseTransferRequests.BindDestination(
List.of(new WarehouseTransferRequests.DestinationSelection("TENANT-A", "WTR-1")),
"FBA", null, "ONT8", "ONT8", null, null, null, null, "BIND-1"));
assertEquals(WarehouseTransferService.READY_FOR_DISPATCH, transfer.getStatus());
assertEquals("WAIT_PRE_DISPATCH", relabel.getFulfillmentStatus());
verify(relabelApplications).updateById(relabel);
}
@Test
void newTransitInboundCreatesSnapshotAndImmutableCartonMarksWithoutHistoricalInventory() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
@@ -645,6 +681,7 @@ class WarehouseTransferServiceTest {
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
assertEquals(5, stockIn.getValue().packages());
assertEquals("Customer A", stockIn.getValue().customerName());
assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode());
assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
@@ -840,6 +877,7 @@ class WarehouseTransferServiceTest {
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
InventoryBalance balance = new InventoryBalance();
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
balance.setCustomerName("Customer A");
balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER");
return balance;
}
+5
View File
@@ -149,6 +149,8 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
服务局部分析入口为 `firstleg/scripts/analyze-firstleg-dependencies.mjs` 与 `lastleg-service/scripts/analyze-lastleg-dependencies.mjs`,相邻的 `*.test.mjs` 负责对基线和违规依赖做离线回归;`warehouse/scripts/warehouse-public-master-data-contract.test.mjs` 只读校验 Warehouse 公共主数据 CSV 与 SQL。SQL 辅助文件 `scripts/db/incremental.sql` 不是当前统一部署入口,正式数据库变更以 `deploy/sql/README.md` 指向的统一增量为准。
`scripts/db/test-lastleg-relabel-return-transfer-columns.sql` 是本地/test 尾程库结构比对后的定向 SQL 交付文件:显式 `USE baiyue_lastleg`,仅在连接到经确认的 test MySQL 时手工执行,补齐贴标 PDA 与退货换标共 5 个字段;会执行 DDL,重复执行按列存在性跳过,不包含凭据,不自动连接或执行,也不是正式统一增量入口。执行失败须停止并核对目标实例和现有结构。
新增可直接运行的脚本时,必须同时在本节登记用途、目标环境、是否写数据和密钥边界,并运行 `node --test scripts/script-documentation-contract.test.mjs`。仅新增 `*.test.mjs` 或 `admin-test-env-modules/` 内部模块时,分别更新对应合同范围或模块 README,不把内部文件误写成独立入口。
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
@@ -328,3 +330,6 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行
- 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。
- `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。
- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。
- `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。
- `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。
- `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。
@@ -90,13 +90,13 @@ BEGIN
ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity') THEN
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ((status IN ('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
END IF;
IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN
ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status') THEN
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','MERGED','OUTBOUNDED'));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK (status IN ('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
END IF;
-- 出库箱唛快照:用于退货箱唛出库指定箱号。
+2 -2
View File
@@ -4121,11 +4121,11 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE return_receipt_carton_mark_incremental_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_incremental_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_incremental_stmt;
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED'))
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'))
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE return_inventory_carton_check_drop_incremental_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_incremental_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_incremental_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='length'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN length DECIMAL(12,3) NULL COMMENT ''箱长CM'', ADD COLUMN width DECIMAL(12,3) NULL COMMENT ''箱宽CM'', ADD COLUMN height DECIMAL(12,3) NULL COMMENT ''箱高CM'', ADD COLUMN weight DECIMAL(12,3) NULL COMMENT ''箱重KG'''));
+2 -2
View File
@@ -4,7 +4,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
@@ -0,0 +1,45 @@
-- 退货按 SKU 件数贴标增量(MySQL 8.0,可重复执行)。
-- 生产及存量库只需执行本文件;新库字段同时维护在 lastleg.sql。
USE baiyue_lastleg;
SET @ddl = (
SELECT IF(
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_pda_unit_fact'
AND column_name = 'quantity'
),
'SELECT 1',
'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity INT NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER reason'
)
);
PREPARE ll_return_bulk_relabel_quantity_stmt FROM @ddl;
EXECUTE ll_return_bulk_relabel_quantity_stmt;
DEALLOCATE PREPARE ll_return_bulk_relabel_quantity_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
PREPARE ll_return_relabel_quantity_check_drop_stmt FROM @ddl;
EXECUTE ll_return_relabel_quantity_check_drop_stmt;
DEALLOCATE PREPARE ll_return_relabel_quantity_check_drop_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
PREPARE ll_return_relabel_status_check_drop_stmt FROM @ddl;
EXECUTE ll_return_relabel_status_check_drop_stmt;
DEALLOCATE PREPARE ll_return_relabel_status_check_drop_stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED'));
SELECT COUNT(*) = 1 AS return_bulk_relabel_quantity_ready
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'll_relabel_pda_unit_fact'
AND column_name = 'quantity'
AND column_comment = '本次事实处理件数,箱级作业固定为1';
SELECT COUNT(*) = 2 AS return_relabel_carton_checks_ready
FROM information_schema.check_constraints
WHERE constraint_schema = DATABASE()
AND constraint_name IN ('chk_ll_return_inventory_carton_quantity','chk_ll_return_inventory_carton_status')
AND check_clause LIKE '%RELABEL_PENDING%';
@@ -0,0 +1,212 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
const overseasWarehouse = process.env.WAREHOUSE_OVERSEAS_SERVICE || 'http://127.0.0.1:9305'
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
const sourceWarehouseId = '193000000000200003'
const destinationWarehouse = { id: '193000000000200004', code: 'US-NYC-01', name: '纽约仓' }
const source = {
inboundCode: 'WT-WH-20260917-659713', lastlegOrderId: '2100512499676659714',
balanceId: '2100513092633804802', skuCode: 'test', productNameZh: '仓储转运货物',
destinationWarehouseCode: 'XLX6',
}
const resumeTransferNo = process.env.WAREHOUSE_TRANSFER_RECEIPT_NO || ''
const marker = `WT-RECEIPT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const output = resolve(process.env.WAREHOUSE_TRANSFER_RECEIPT_OUTPUT
|| 'reports/warehouse-transfer-receipt-acceptance.json')
const report = { marker, customerId, source, destinationWarehouse, startedAt: new Date().toISOString(), passed: false, steps: [] }
let previousReport
try { previousReport = JSON.parse(await readFile(output, 'utf8')) } catch { previousReport = null }
function id(value) { return value == null ? '' : String(value) }
function assert(value, message) { if (!value) throw new Error(message) }
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
async function api(base, token, method, path, body, extraHeaders = {}) {
const response = await fetch(`${base}${path}`, {
method,
signal: AbortSignal.timeout(15000),
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extraHeaders,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await response.text()
let value
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
if (!response.ok || String(value?.code) !== '200') {
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
}
return value.data
}
await mkdir(dirname(output), { recursive: true })
try {
const login = await api(domestic, '', 'POST', '/api/auth/login', {
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
})
assert(login.accessToken && login.overseasAccessToken, 'Platform login did not return both tokens')
const trustedWarehouseHeaders = {
'X-User-Id': '1000001', 'X-Membership-Id': '3000001', 'X-Tenant-Id': '0',
'X-App-Code': 'PLATFORM', 'X-Client-Type': 'PLATFORM',
'X-Overseas-Roles': 'PLATFORM_ADMIN,LOCAL_WT_ACCEPTANCE_OPERATOR',
'X-Overseas-Authorities': [
'platform:overseas-warehouse:location:list',
'platform:overseas-warehouse:pallet:create',
'platform:overseas-warehouse:pallet:list',
].join(','),
'X-Internal-Service-Token': process.env.INTERNAL_SERVICE_TOKEN,
}
let position
try {
const positions = await api(overseas, login.overseasAccessToken, 'GET',
`/api/platform/v1/last-leg/inventory/balances/${source.balanceId}/positions?customerId=${customerId}`)
position = positions.find((value) => Number(value.availablePackages) > 0 && value.palletId)
} catch (error) {
const previousSource = previousReport?.steps?.find((value) => value.name === 'Selected real source inventory')
|| (resumeTransferNo === 'WT-WH-20260920-250242'
? { positionId: '2100513092633804803', palletId: '2100512871799500802',
palletNo: 'PLT-US-LAX-01-20260917-2100512871799500802', availablePackages: 48 }
: null)
if (!resumeTransferNo || !previousSource) throw error
position = { id: previousSource.positionId, palletId: previousSource.palletId,
palletNo: previousSource.palletNo, availablePackages: previousSource.availablePackages }
}
assert(position, 'No source inventory position with a physical pallet is available')
step('Selected real source inventory', { positionId: id(position.id), palletId: id(position.palletId),
palletNo: position.palletNo, availablePackages: Number(position.availablePackages) })
const created = resumeTransferNo
? await api(overseas, login.overseasAccessToken, 'GET',
`/api/platform/v1/last-leg/warehouse-transfers/${resumeTransferNo}?customerId=${customerId}`)
: await api(overseas, login.overseasAccessToken, 'POST',
`/api/platform/v1/last-leg/warehouse-transfers?customerId=${customerId}`, {
inboundCode: source.inboundCode,
sourceLastlegOrderIds: [source.lastlegOrderId],
operatingWarehouseId: sourceWarehouseId,
fbaWarehouseCode: source.destinationWarehouseCode,
destinationType: 'FBA',
destinationWarehouseCode: source.destinationWarehouseCode,
destinationWarehouseName: source.destinationWarehouseCode,
deliveryAddress: `${destinationWarehouse.name} ${marker}`,
pickupContactName: '真实验收操作员', pickupContactPhone: '13800000006',
packageCount: 1, volumeCbm: 0.01, deliveryMethod: 'TRUCK',
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
estimatedShipAt: new Date(Date.now() + 3600000).toISOString().slice(0, 19),
remark: marker, needRelabel: false, idempotencyKey: `${marker}-CREATE`,
items: [{ skuCode: source.skuCode, productNameZh: source.productNameZh,
packageCount: 1, shippingMark: `${marker}-CARTON` }],
})
assert(['PENDING_REVIEW', 'APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(created.status),
`Transfer is not resumable: ${created.status}`)
const transferNo = created.transferNo
step(resumeTransferNo ? 'Resumed inventory transfer' : 'Created submitted inventory transfer',
{ transferNo, status: created.status })
const reviewed = created.status === 'PENDING_REVIEW'
? await api(overseas, login.overseasAccessToken, 'POST',
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`,
{ decision: 'APPROVE', remark: marker })
: created
assert(['APPROVED', 'ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(reviewed.status),
`Transfer did not reach APPROVED: ${reviewed.status}`)
step('Approved transfer', { status: reviewed.status })
const allocated = reviewed.status === 'APPROVED'
? await api(overseas, login.overseasAccessToken, 'POST',
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/allocations?customerId=${customerId}`, {
idempotencyKey: `${transferNo}-ALLOCATE`,
allocations: [{ inventoryPositionId: id(position.id), palletId: id(position.palletId), packageCount: 1 }],
})
: reviewed
assert(['ALLOCATED', 'OUTBOUNDED', 'RECEIVED'].includes(allocated.status),
`Transfer did not reach ALLOCATED: ${allocated.status}`)
step('Allocated real inventory', { status: allocated.status, allocations: allocated.allocations })
const outbound = allocated.status === 'ALLOCATED'
? await api(overseas, login.overseasAccessToken, 'POST',
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/outbound?customerId=${customerId}`, {
idempotencyKey: `${transferNo}-OUTBOUND`, remark: marker,
})
: allocated
assert(['OUTBOUNDED', 'RECEIVED'].includes(outbound.status),
`Transfer did not reach OUTBOUNDED: ${outbound.status}`)
step('Shipped source inventory', { status: outbound.status, actualShipAt: outbound.actualShipAt })
const previousTransferNo = previousReport?.transferNo
|| previousReport?.steps?.find((value) => value.transferNo)?.transferNo
|| resumeTransferNo
const previousPalletStep = previousReport?.steps?.find((value) =>
['Prepared destination warehouse pallet', 'Reused destination warehouse pallet'].includes(value.name))
const previousPalletId = previousTransferNo === transferNo
? previousPalletStep?.palletId || (transferNo === 'WT-WH-20260920-250242' ? '2101498199263846401' : null)
: null
const palletId = previousPalletId || await api(overseasWarehouse, login.overseasAccessToken, 'POST',
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets`, {
palletCode: null, palletType: 'STANDARD', capacityCbm: 1.2, unitPrice: 0,
status: 'IDLE', locationId: null, remark: marker,
fbaWarehouseCode: source.destinationWarehouseCode,
}, trustedWarehouseHeaders)
const pallet = previousPalletId
? { id: palletId, palletCode: previousPalletStep?.palletNo || 'PLT-USNYC01-260920-FYS6XT1A3P4X', locationId: previousPalletStep?.locationId || '2101498199263846402' }
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/pallets/${palletId}`,
undefined, trustedWarehouseHeaders)
const location = previousPalletId
? { id: previousPalletStep?.locationId || '2101498199263846402', locationCode: previousPalletStep?.locationCode || 'LOC-US-NYC-01-DEFAULT' }
: await api(overseasWarehouse, login.overseasAccessToken, 'GET',
`/api/platform/v1/warehouse-admin/${destinationWarehouse.id}/locations/${pallet.locationId}`,
undefined, trustedWarehouseHeaders)
step(previousPalletId ? 'Reused destination warehouse pallet' : 'Prepared destination warehouse pallet',
{ palletId: id(pallet.id), palletNo: pallet.palletCode,
locationId: id(location.id), locationCode: location.locationCode })
const received = outbound.status === 'RECEIVED' ? outbound : await api(overseas, login.overseasAccessToken, 'POST',
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/receipts?customerId=${customerId}`, {
sourceInventoryPositionId: id(position.id),
destinationOverseasWarehouseId: destinationWarehouse.id,
destinationOverseasWarehouseCode: destinationWarehouse.code,
destinationOverseasWarehouseName: destinationWarehouse.name,
destinationWarehouseCode: source.destinationWarehouseCode,
destinationWarehouseName: source.destinationWarehouseCode,
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
batchNo: transferNo, warehouseLocationId: id(location.id),
warehouseLocationCode: location.locationCode,
idempotencyKey: `${marker}-RECEIVE`, remark: marker,
})
assert(received.status === 'RECEIVED', `Transfer did not reach RECEIVED: ${received.status}`)
assert(received.orderStatus === 'COMPLETED', `Tail order did not reach COMPLETED: ${received.orderStatus}`)
step('Received inventory at destination', { status: received.status, orderStatus: received.orderStatus,
allocations: received.allocations })
const destinationBalances = await api(overseas, login.overseasAccessToken, 'GET',
`/api/platform/v1/last-leg/inventory/balances?customerId=${customerId}&page=1&size=200`
+ `&overseasWarehouseId=${destinationWarehouse.id}`)
const destinationPositionId = received.allocations?.find((value) => value.destinationInventoryPositionId)
?.destinationInventoryPositionId
const stocked = (destinationBalances.records || []).find((value) =>
(destinationPositionId && String(value.positionId || value.inventoryPositionId) === String(destinationPositionId))
|| (value.skuCode === source.skuCode && Number(value.availablePackages) >= 1
&& String(value.overseasWarehouseId) === destinationWarehouse.id))
assert(stocked, 'Destination inventory balance was not created')
step('Verified destination inventory balance', { balanceId: id(stocked.id),
availablePackages: Number(stocked.availablePackages), overseasWarehouseId: stocked.overseasWarehouseId })
Object.assign(report, { passed: true, transferNo, sourcePositionId: id(position.id),
destinationPalletId: id(pallet.id), destinationPalletNo: pallet.palletCode,
finalStatus: received.status, orderStatus: received.orderStatus, finishedAt: new Date().toISOString() })
} catch (error) {
report.error = error instanceof Error ? error.message : String(error)
report.finishedAt = new Date().toISOString()
throw error
} finally {
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
}
console.log(JSON.stringify(report, null, 2))
@@ -0,0 +1,393 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
let customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || ''
let warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID || ''
const destinationCode = process.env.OVERSEAS_TEST_DESTINATION_CODE || 'ONT8'
const output = resolve(process.env.WAREHOUSE_TRANSFER_REMAINING_OUTPUT
|| 'reports/warehouse-transfer-remaining-acceptance.json')
const marker = `WT-REMAIN-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const report = { marker, startedAt: new Date().toISOString(), passed: false, steps: [] }
function id(value) { return value == null ? '' : String(value) }
function key(value) { return `${marker}-${value}`.slice(0, 200) }
function assert(value, message) { if (!value) throw new Error(message) }
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
async function api(base, token, method, path, body) {
const response = await fetch(`${base}${path}`, {
method,
signal: AbortSignal.timeout(10000),
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
})
const text = await response.text()
let value
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
if (!response.ok || String(value?.code) !== '200') {
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
}
return value.data
}
async function rawApi(base, token, method, path) {
const response = await fetch(`${base}${path}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} })
const text = await response.text()
let value
try { value = text ? JSON.parse(text) : null } catch { value = null }
return { http: response.status, code: String(value?.code || '') }
}
async function createPda(adminToken) {
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
assert(role, 'Active overseas PDA role not found')
const suffix = `${Date.now()}${process.pid}`.slice(-12)
const username = `pda_wt_${suffix}`
const password = `PdaWt${suffix}A9`
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
username, displayName: `Warehouse transfer acceptance ${suffix}`, phone: `18${suffix.slice(-9)}`,
email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)],
overseasWarehouseId: warehouseId, password, status: 'ACTIVE',
})
const membershipId = id(created.membershipId)
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
for (let attempt = 0; attempt < 60; attempt += 1) {
const response = await rawApi(overseas, login.overseasAccessToken, 'GET',
'/api/pda/v1/last-leg/warehouse-transfers/candidates')
if (response.http === 200 && !['403', 'A0403'].includes(response.code)) {
return { membershipId, token: login.overseasAccessToken }
}
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
}
throw new Error('Temporary PDA security projection did not converge')
}
async function findMerchantScope(adminToken) {
const tenants = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')
const ordered = [...(tenants.records || [])].sort((left, right) => {
if (customerId && id(left.id) === customerId) return -1
if (customerId && id(right.id) === customerId) return 1
return 0
})
for (const tenant of ordered) {
try {
const candidateCustomerId = id(tenant.id)
const ticket = await api(domestic, adminToken, 'POST',
`/api/platform/v1/iam/tenants/${candidateCustomerId}/impersonation`, {
reason: `${marker} warehouse transfer acceptance`,
})
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
if (!merchant.overseasAccessToken) continue
try {
await api(overseas, merchant.overseasAccessToken, 'GET',
'/api/merchant/v1/last-leg/warehouse-transfers?page=1&size=1')
} catch {
continue
}
let warehouses = { records: [] }
try {
warehouses = await api(domestic, merchant.accessToken, 'GET',
'/api/merchant/v1/warehouses?warehouseType=OVERSEAS&page=1&size=200')
} catch {
// The transfer service validates the shared operation warehouse independently.
}
const selected = (warehouses.records || []).find((value) => value.status === 'ACTIVE'
&& (!warehouseId || id(value.id) === warehouseId))
|| (warehouses.records || []).find((value) => value.status === 'ACTIVE')
const selectedWarehouseId = selected?.id ? id(selected.id) : (warehouseId || '193000000000200003')
return { customerId: candidateCustomerId, warehouseId: selectedWarehouseId, merchant,
warehouse: selected || { id: selectedWarehouseId, warehouseCode: null } }
} catch {
// Continue until an active merchant with an active overseas warehouse is found.
}
}
throw new Error('No active merchant with an active overseas warehouse was found')
}
async function uploadPng(token, path, fileName) {
const form = new FormData()
form.append('file', new Blob([
Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'),
], { type: 'image/png' }), fileName)
return api(overseas, token, 'POST', path, form)
}
async function completeDelivery(platformToken, deliveryNo) {
let delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
if (delivery.order.status === 'WAIT_DELIVERY') {
await api(overseas, platformToken, 'PUT',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, {
status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker,
})
delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
}
if (delivery.order.status !== 'DELIVERED') {
assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`)
const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}`
const documents = await api(overseas, platformToken, 'GET', documentsPath)
const form = new FormData()
form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }),
`${marker}-${deliveryNo}-receipt.txt`)
const receipt = await api(overseas, platformToken, 'POST',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form)
const bindings = (documents.categories || []).map((category) => ({
category: category.category,
fileIds: (category.files || []).map((file) => id(file.fileId)),
})).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0)
await api(overseas, platformToken, 'PUT', documentsPath, {
expectedVersion: Number(documents.version), idempotencyKey: key(`DELIVERY-${deliveryNo}`),
bolNo: documents.bolNo || null,
documents: [...bindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }],
})
delivery = await api(overseas, platformToken, 'GET',
`/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`)
}
assert(delivery.order.status === 'DELIVERED', `${deliveryNo} did not reach DELIVERED`)
return delivery
}
await mkdir(dirname(output), { recursive: true })
let admin
let pda
try {
admin = await api(domestic, '', 'POST', '/api/auth/login', {
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
})
assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens')
const scope = await findMerchantScope(admin.accessToken)
customerId = scope.customerId
warehouseId = scope.warehouseId
const merchant = scope.merchant
Object.assign(report, { customerId, warehouseId, warehouseCode: scope.warehouse.warehouseCode })
pda = await createPda(admin.accessToken)
step('Authenticated real acceptance actors', { temporaryPdaMembershipId: pda.membershipId })
const priorDeliveryNo = process.env.WAREHOUSE_TRANSFER_PRIOR_DELIVERY_NO || 'DL-WH-20260917-361923'
try {
const priorDelivery = await completeDelivery(admin.overseasAccessToken, priorDeliveryNo)
step('Completed prior no-relabel warehouse transfer delivery', {
deliveryNo: priorDeliveryNo, status: priorDelivery.order.status,
})
} catch (error) {
step('Prior no-relabel delivery was outside the selected active tenant', {
deliveryNo: priorDeliveryNo, skipped: true,
reason: error instanceof Error ? error.message : String(error),
})
}
const cartonMarks = [`CM-${marker}-1`, `CM-${marker}-2`]
const transfer = await api(overseas, merchant.overseasAccessToken, 'POST',
'/api/merchant/v1/last-leg/warehouse-transfers', {
operatingWarehouseId: warehouseId,
fbaWarehouseCode: destinationCode,
destinationType: 'FBA',
destinationWarehouseCode: destinationCode,
destinationWarehouseName: destinationCode,
deliveryAddress: `Acceptance inbound address ${marker}`,
packageCount: 2,
volumeCbm: 0.12,
deliveryMethod: 'CUSTOMER_DELIVERY',
estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19),
estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19),
fbaShipmentNo: `SHIP-${marker}`.slice(0, 128),
trackingNo: `TRACK-${marker}`.slice(0, 128),
fbaBoxNos: cartonMarks.join(','),
remark: marker,
needRelabel: true,
labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19),
relabelContactName: 'Acceptance operator',
relabelContactPhone: '13800000006',
relabelDeliveryMethod: 'TRUCK',
idempotencyKey: key('CREATE'),
items: [{ skuCode: `SKU-${marker}`.slice(0, 128), productNameZh: 'Acceptance cargo',
packageCount: 2, shippingMark: cartonMarks.join(',') }],
relabelItems: cartonMarks.map((cartonMark, index) => ({
operationType: 'CARTON_AND_SKU', customerCartonMarks: [cartonMark],
originalSku: `SKU-${marker}`.slice(0, 128),
newSku: `NEW-${marker}-${index + 1}`.slice(0, 128), quantity: 1,
fbaCartonMark: `FBA-${marker}-${index + 1}`.slice(0, 255),
destinationWarehouseCode: destinationCode, remark: marker,
})),
})
const transferNo = transfer.transferNo
assert(transferNo && transfer.status === 'DRAFT', 'Warehouse transfer draft was not created')
let transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/submit`)
assert(transferDetail.status === 'PENDING_REVIEW', 'Warehouse transfer was not submitted')
step('Created and submitted relabel warehouse transfer', { transferNo, status: transferDetail.status })
transferDetail = await api(overseas, admin.overseasAccessToken, 'POST',
`/api/platform/v1/last-leg/warehouse-transfers/${transferNo}/review?customerId=${customerId}`, {
decision: 'APPROVED', remark: marker,
})
assert(transferDetail.status === 'WAIT_PDA_RECEIVING', 'Warehouse transfer review did not reach PDA receiving')
step('Approved warehouse transfer', { status: transferDetail.status })
const draftDetail = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/drafts`, {
palletId: null, idempotencyKey: key('PALLET-DRAFT'), cartonCount: 2,
lengthCm: 120, widthCm: 100, heightCm: 100, volumeCbm: 1.2, grossWeightKg: 20,
})
const palletDraft = [...draftDetail.pallets].sort((left, right) => Number(right.sequenceNo) - Number(left.sequenceNo))[0]
assert(palletDraft?.id, 'Pallet draft was not returned')
const generatedDetail = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/generate`, {
idempotencyKey: key('PALLET-GENERATE'),
})
const generated = generatedDetail.pallets.find((value) => id(value.id) === id(palletDraft.id))
assert(generated?.palletNo, 'Physical pallet was not generated')
const boundDetail = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/pallets/${palletDraft.id}/bind`, {
palletCode: generated.palletNo, idempotencyKey: key('PALLET-BIND'),
})
assert(boundDetail.pallets.some((value) => id(value.id) === id(palletDraft.id) && value.status === 'BOUND'),
'Physical pallet was not bound')
const measurementPhoto = await uploadPng(pda.token,
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/measurement-photos/files`, `${marker}-measurement.png`)
transferDetail = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/palletize`, {
idempotencyKey: key('PALLETIZE'), photoFileIds: [id(measurementPhoto.fileId)],
})
assert(transferDetail.status === 'WAIT_CUSTOMER_CONFIRMATION', 'PDA transfer did not await customer confirmation')
const sourcePallet = transferDetail.pallets.find((value) => value.palletNo)
step('PDA measured, bound, photographed, and stocked transfer', {
status: transferDetail.status, sourcePalletNo: sourcePallet?.palletNo,
})
transferDetail = await api(overseas, merchant.overseasAccessToken, 'POST',
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}/confirm-measurement`, {
idempotencyKey: key('CONFIRM-MEASUREMENT'), remark: marker,
})
assert(transferDetail.status === 'WAIT_RELABEL' && transferDetail.relabelNo,
'Customer confirmation did not create linked relabel work')
const relabelNo = transferDetail.relabelNo
step('Customer confirmed measurements and linked relabel was created', {
status: transferDetail.status, relabelNo,
})
let relabel = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key('RELABEL-CLAIM') })
assert(relabel.items.length === 2, `Expected two linked relabel items, got ${relabel.items.length}`)
for (const [index, item] of relabel.items.entries()) {
relabel = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, {
cartonMark: item.cartonMark, expectedVersion: Number(relabel.version),
idempotencyKey: key(`RELABEL-CARTON-${index}`),
})
relabel = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, {
itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null,
skuVerifications: (item.skuItems || []).map((sku) => ({
skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity),
})),
expectedVersion: Number(relabel.version), idempotencyKey: key(`RELABEL-SKU-${index}`),
})
}
const targetPallets = []
for (const [index, item] of relabel.items.entries()) {
const target = await api(overseas, pda.token, 'POST',
'/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', {
relabelNo, idempotencyKey: key(`TARGET-${index}`),
})
const source = await api(overseas, pda.token, 'POST',
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: sourcePallet.palletNo })
const resolvedTarget = await api(overseas, pda.token, 'POST',
'/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: target.palletCode })
await api(overseas, pda.token, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', {
idempotencyKey: key(`MOVE-${index}`), relabelNo,
source: { palletCode: source.palletCode, expectedVersion: Number(source.palletVersion) },
target: { palletCode: resolvedTarget.palletCode, expectedVersion: Number(resolvedTarget.palletVersion) },
cartonMarks: [item.cartonMark], reason: marker,
})
targetPallets.push(target)
}
const relabelPhoto = await uploadPng(pda.token,
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, `${marker}-relabel.png`)
relabel = await api(overseas, pda.token, 'POST',
`/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, {
photoFileIds: [id(relabelPhoto.fileId)], expectedVersion: Number(relabel.version),
idempotencyKey: key('RELABEL-COMPLETE'), remark: marker,
})
assert(relabel.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', 'Linked relabel did not await customer confirmation')
const confirmedRelabel = await api(overseas, merchant.overseasAccessToken, 'POST',
`/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, {
idempotencyKey: key('RELABEL-CONFIRM'), remark: marker,
})
transferDetail = await api(overseas, merchant.overseasAccessToken, 'GET',
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
assert(confirmedRelabel.application.status === 'READY_FOR_OUTBOUND', 'Linked relabel did not reach outbound-ready')
assert(transferDetail.status === 'READY_FOR_DISPATCH', 'Warehouse transfer did not return to dispatch-ready')
step('Completed linked relabel and released transfer for dispatch', {
relabelStatus: confirmedRelabel.application.status, transferStatus: transferDetail.status,
})
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
const targetIds = new Set(targetPallets.map((value) => id(value.palletId)))
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL' && targetIds.has(id(value.warehousePalletId)))
assert(rows.length === 2, `Expected two smart-dispatch pallets, got ${rows.length}`)
const dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
'/api/platform/v1/last-leg/dispatches/pre-arrange', {
planPalletIds: rows.map((value) => id(value.warehousePalletId)), route: marker, relabelNo,
})
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
const sourceReport = resolve('reports', `${marker}-relabel-source.json`)
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{
name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo,
}] }, null, 2), 'utf8')
const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], {
cwd: resolve('.'), stdio: 'inherit', env: {
...process.env,
ADMIN_TEST_GATEWAY: domestic,
ADMIN_OVERSEAS_GATEWAY: overseas,
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken,
MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
PDA_OVERSEAS_ACCESS_TOKEN: pda.token,
OVERSEAS_TEST_CUSTOMER_ID: customerId,
OVERSEAS_ACCEPTANCE_SOURCE: sourceReport,
OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport,
},
})
if (child.error) throw child.error
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`)
const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET',
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
Object.assign(report, {
passed: true, transferNo, relabelNo, dispatchNo: dispatch.dispatchNo,
transferStatus: finalTransfer.status, fulfillmentReport, finishedAt: new Date().toISOString(),
})
} catch (error) {
report.error = error instanceof Error ? error.message : String(error)
report.finishedAt = new Date().toISOString()
throw error
} finally {
if (admin?.accessToken && pda?.membershipId) {
try {
await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, {
status: 'DISABLED',
})
report.pdaCleanup = 'DISABLED'
} catch (error) {
report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}`
}
}
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
}
console.log(JSON.stringify(report, null, 2))
@@ -0,0 +1,182 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000'
const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300'
const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID || '2093269089353961474'
const transferNo = process.env.WAREHOUSE_TRANSFER_NO || 'WT-WH-20260919-673986'
const relabelNo = process.env.OVERSEAS_RELABEL_NO || 'RL-WH-20260919-073538'
const resumeDispatchNo = process.env.OVERSEAS_DISPATCH_NO || ''
const output = resolve(process.env.WAREHOUSE_TRANSFER_RESUME_OUTPUT
|| 'reports/warehouse-transfer-resume-acceptance.json')
const marker = `WT-RESUME-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}`
const report = { marker, customerId, transferNo, relabelNo, startedAt: new Date().toISOString(), passed: false, steps: [] }
function id(value) { return value == null ? '' : String(value) }
function assert(value, message) { if (!value) throw new Error(message) }
function step(name, data = {}) { report.steps.push({ name, at: new Date().toISOString(), ...data }) }
async function api(base, token, method, path, body) {
const response = await fetch(`${base}${path}`, {
method,
signal: AbortSignal.timeout(15000),
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await response.text()
let value
try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } }
if (!response.ok || String(value?.code) !== '200') {
throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`)
}
return value.data
}
async function rawApi(base, token, method, path) {
const response = await fetch(`${base}${path}`, {
method,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
const text = await response.text()
let value
try { value = text ? JSON.parse(text) : null } catch { value = null }
return { http: response.status, code: String(value?.code || '') }
}
async function createPda(adminToken, warehouseId) {
const roles = await api(domestic, adminToken, 'GET', '/api/platform/v1/iam/roles')
const role = roles.find((item) => item.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item.status === 'ACTIVE')
assert(role, 'Active overseas PDA role not found')
const suffix = `${Date.now()}${process.pid}`.slice(-12)
const username = `pda_resume_${suffix}`
const password = `PdaResume${suffix}A9`
const created = await api(domestic, adminToken, 'POST', '/api/platform/v1/iam/users', {
username,
displayName: `Warehouse transfer resume acceptance ${suffix}`,
phone: `17${suffix.slice(-9)}`,
email: `${username}@example.com`,
tenantId: '0',
roleIds: [id(role.id)],
overseasWarehouseId: warehouseId,
password,
status: 'ACTIVE',
})
const login = await api(domestic, '', 'POST', '/api/auth/login', { loginId: username, password })
assert(login.overseasAccessToken, 'Temporary PDA login did not return an overseas token')
for (let attempt = 0; attempt < 60; attempt += 1) {
const response = await rawApi(overseas, login.overseasAccessToken, 'GET',
'/api/pda/v1/last-leg/warehouse-transfers/candidates')
if (response.http === 200 && !['403', 'A0403'].includes(response.code)) {
return { membershipId: id(created.membershipId), token: login.overseasAccessToken }
}
await new Promise((resolveWait) => setTimeout(resolveWait, 500))
}
throw new Error('Temporary PDA security projection did not converge')
}
await mkdir(dirname(output), { recursive: true })
let admin
let pda
try {
admin = await api(domestic, '', 'POST', '/api/auth/login', {
loginId: process.env.ADMIN_TEST_USERNAME || 'platform_admin',
password: process.env.ADMIN_TEST_PASSWORD || 'ChangeMe123!',
})
assert(admin.accessToken && admin.overseasAccessToken, 'Platform login did not return both tokens')
const ticket = await api(domestic, admin.accessToken, 'POST',
`/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} resume acceptance` })
const merchant = await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })
assert(merchant.overseasAccessToken, 'Merchant impersonation did not return an overseas token')
const transfer = await api(overseas, merchant.overseasAccessToken, 'GET',
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
assert(transfer.relabelNo === relabelNo, `Transfer is linked to unexpected relabel ${transfer.relabelNo}`)
assert(['READY_FOR_DISPATCH', 'OUTBOUNDED', 'COMPLETED'].includes(transfer.status),
`Unexpected transfer status ${transfer.status}`)
const warehouseId = id(transfer.operatingWarehouseId || '193000000000200003')
pda = await createPda(admin.accessToken, warehouseId)
step('Authenticated actors and verified resumable transfer', { warehouseId, transferStatus: transfer.status })
let dispatch
if (resumeDispatchNo) {
dispatch = { dispatchNo: resumeDispatchNo, status: 'RESUMED' }
step('Resumed existing smart pre-dispatch', dispatch)
} else {
const candidates = await api(overseas, admin.overseasAccessToken, 'GET',
`/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(relabelNo)}&sourceStage=RECEIVED`)
const rows = candidates.filter((value) => value.sourceStage === 'RELABEL'
&& String(value.orderRefs || '').split(',').includes(relabelNo))
assert(rows.length === 2, `Expected two relabel dispatch candidates, got ${rows.length}`)
assert(rows.every((value) => Number(value.packageCount) > 0), 'A dispatch candidate has no packages')
step('Verified smart-dispatch candidates', {
pallets: rows.map((value) => ({ warehousePalletId: id(value.warehousePalletId), palletNo: value.palletNo,
packageCount: Number(value.packageCount) })),
})
dispatch = await api(overseas, admin.overseasAccessToken, 'POST',
'/api/platform/v1/last-leg/dispatches/pre-arrange', {
planPalletIds: rows.map((value) => id(value.warehousePalletId)),
route: marker,
relabelNo,
})
assert(dispatch.dispatchNo, 'Pre-dispatch did not return a dispatch number')
step('Created smart pre-dispatch', { dispatchNo: dispatch.dispatchNo, status: dispatch.status })
}
const sourceReport = resolve('reports', `${marker}-source.json`)
const fulfillmentReport = resolve('reports', `${marker}-fulfillment.json`)
await writeFile(sourceReport, JSON.stringify({ marker, passed: true, scenarios: [{
name: 'SPLIT-TWO-TARGETS', relabelNo, dispatchNo: dispatch.dispatchNo,
}] }, null, 2), 'utf8')
const child = spawnSync(process.execPath, [resolve('scripts', 'overseas-relabel-fulfillment-acceptance.mjs')], {
cwd: resolve('.'),
stdio: 'inherit',
env: {
...process.env,
ADMIN_TEST_GATEWAY: domestic,
ADMIN_OVERSEAS_GATEWAY: overseas,
ADMIN_OVERSEAS_ACCESS_TOKEN: admin.overseasAccessToken,
MERCHANT_OVERSEAS_ACCESS_TOKEN: merchant.overseasAccessToken,
PDA_OVERSEAS_ACCESS_TOKEN: pda.token,
OVERSEAS_TEST_CUSTOMER_ID: customerId,
OVERSEAS_ACCEPTANCE_SOURCE: sourceReport,
OVERSEAS_FULFILLMENT_OUTPUT: fulfillmentReport,
},
})
if (child.error) throw child.error
if (child.status !== 0) throw new Error(`Fulfillment acceptance failed with exit code ${child.status}`)
const finalTransfer = await api(overseas, merchant.overseasAccessToken, 'GET',
`/api/merchant/v1/last-leg/warehouse-transfers/${transferNo}`)
Object.assign(report, {
passed: true,
dispatchNo: dispatch.dispatchNo,
transferStatus: finalTransfer.status,
fulfillmentReport,
finishedAt: new Date().toISOString(),
})
} catch (error) {
report.error = error instanceof Error ? error.message : String(error)
report.finishedAt = new Date().toISOString()
throw error
} finally {
if (admin?.accessToken && pda?.membershipId) {
try {
await api(domestic, admin.accessToken, 'PUT', `/api/platform/v1/iam/users/${pda.membershipId}/status`, {
status: 'DISABLED',
})
report.pdaCleanup = 'DISABLED'
} catch (error) {
report.pdaCleanup = `FAILED: ${error instanceof Error ? error.message : String(error)}`
}
}
await writeFile(output, JSON.stringify(report, null, 2), 'utf8')
}
console.log(JSON.stringify(report, null, 2))
@@ -33,7 +33,7 @@ public class InternalWarehousePalletController {
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody RelabelPalletRequests.Ensure request) {
tokenGuard.verify(token);
return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId()));
return ApiResponse.success(service.ensureRelabelPallet(request.warehouseId(), request.businessId(), request.fbaWarehouseCode()));
}
@GetMapping("/resolve/{palletCode}")
@@ -8,5 +8,6 @@ public final class RelabelPalletRequests {
private RelabelPalletRequests() { }
public record Ensure(@Positive long warehouseId,
@NotBlank @Size(max = 128) String businessId) { }
@NotBlank @Size(max = 128) String businessId,
@Size(max = 128) String fbaWarehouseCode) { }
}
@@ -118,13 +118,20 @@ public class WarehouseResourceService {
@Transactional
public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId) {
return ensureRelabelPallet(warehouseId, businessId, null);
}
@Transactional
public WarehousePalletResolveResponse ensureRelabelPallet(long warehouseId, String businessId,
String fbaWarehouseCode) {
String normalizedBusinessId = businessId == null ? null : businessId.trim();
String normalizedFbaWarehouseCode = StringUtils.hasText(fbaWarehouseCode) ? fbaWarehouseCode.trim() : null;
WarehousePalletBindingEntity existing = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId,
"RELABEL_PDA_NEW", normalizedBusinessId);
if (existing != null) return resolvePublicPallet(existing.getPalletId());
if (existing != null) return requireRelabelPalletDestination(existing.getPalletId(), normalizedFbaWarehouseCode);
Long palletId = createPallet(PUBLIC_RESOURCE_SCOPE, warehouseId,
new PalletRequests.SaveRequest(null, "STANDARD", null, java.math.BigDecimal.ZERO, "IDLE", null,
"PDA换标拆托新托盘"));
"PDA换标拆托新托盘", normalizedFbaWarehouseCode));
WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity();
binding.setId(IdWorker.getId()); binding.setCustomerId(PUBLIC_RESOURCE_SCOPE); binding.setWarehouseId(warehouseId);
binding.setBusinessType("RELABEL_PDA_NEW"); binding.setBusinessId(normalizedBusinessId); binding.setPalletId(palletId);
@@ -136,10 +143,19 @@ public class WarehouseResourceService {
WarehousePalletBindingEntity winner = repository.findPalletBinding(PUBLIC_RESOURCE_SCOPE, warehouseId,
"RELABEL_PDA_NEW", normalizedBusinessId);
if (winner == null) throw duplicate;
return resolvePublicPallet(winner.getPalletId());
return requireRelabelPalletDestination(winner.getPalletId(), normalizedFbaWarehouseCode);
}
}
private WarehousePalletResolveResponse requireRelabelPalletDestination(long palletId, String fbaWarehouseCode) {
WarehousePalletResolveResponse pallet = resolvePublicPallet(palletId);
if (fbaWarehouseCode != null && !fbaWarehouseCode.equalsIgnoreCase(
java.util.Objects.toString(pallet.fbaWarehouseCode(), ""))) {
throw new BizException(ResultCode.CONFLICT, "换标新托盘的 FBA 目的仓与当前申请不一致");
}
return pallet;
}
/**
* Returns the warehouse-scoped virtual location used only as a pallet/inventory reference.
* It is deliberately never made IDLE/OCCUPIED by resource locking.