贴标申请,仓储转运

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
@@ -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()),