贴标申请,仓储转运

This commit is contained in:
2026-09-20 15:06:17 +08:00
parent a7d458cddd
commit 02844da360
56 changed files with 2121 additions and 111 deletions
@@ -1,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("当前换标状态不能组托");