贴标申请,仓储转运

This commit is contained in:
2026-09-17 17:37:29 +08:00
parent 3b8b6f5aa2
commit 29f05b7b09
106 changed files with 5019 additions and 471 deletions
@@ -28,6 +28,13 @@ public class PdaPalletOperationController {
return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-sources")
public ApiResponse<java.util.List<PalletOperationResponses.RelabelSource>> relabelSources(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody PalletOperationRequests.RelabelSources request) {
return ApiResponse.success(service.relabelSources(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/relabel-pallet-label")
public ApiResponse<PalletOperationResponses.PrintablePallet> createRelabelPallet(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -13,6 +13,7 @@ public final class PalletOperationRequests {
private PalletOperationRequests() { }
public record Scan(@NotBlank @Size(max = 128) String code) { }
public record RelabelSources(@NotBlank @Size(max = 64) String relabelNo) { }
public record NewRelabelPallet(@NotBlank @Size(max = 64) String relabelNo,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode,
@@ -33,13 +34,19 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
/** 换标场景按箱唛拆托并绑定到一个目标托盘。箱唛位置由服务端从换标单快照解析。 */
/** actualSource为现场确认的原入仓库存行;cartonMarks确认本批目标绑定,不推断历史箱唛位置。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo,
@Valid PalletExpectation source,
@Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@Size(max = 500) String reason) { }
@Size(max = 500) String reason,
@Valid MoveLine actualSource) {
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
PalletExpectation target, List<String> cartonMarks, String reason) {
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null);
}
}
public record Swap(@NotBlank @Size(max = 128) String idempotencyKey,
@Valid PalletExpectation source,
@@ -5,6 +5,9 @@ import java.util.List;
public final class PalletOperationResponses {
private PalletOperationResponses() { }
public record RelabelSource(long positionId, long positionVersion, String palletCode,
String locationCode, String inboundCode, long stockPackages,
long movablePackages, List<String> cartonMarks) { }
public record InventoryLine(String customerId, String customerName, long balanceId, long positionId,
String inboundCode, String skuCode, String productName,
@@ -23,6 +23,12 @@ public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation>
PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey,
@Param("idempotencyKey") String idempotencyKey);
@Select(CONTENT_SELECT + "WHERE b.customer_id=#{customerId} AND b.overseas_warehouse_id=#{warehouseId} " +
"AND b.id=#{balanceId} AND p.pallet_id>0 " +
"AND (p.available_packages+p.hold_packages)>0 ORDER BY p.pallet_no,p.id")
List<PalletInventoryRow> relabelSources(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("balanceId") long balanceId);
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
"ORDER BY b.customer_id,b.inbound_code,p.id")
@@ -78,13 +78,42 @@ public class PdaPalletOperationService {
return content(identity.overseasWarehouseId(), pallet);
}
public List<PalletOperationResponses.RelabelSource> relabelSources(PdaWarehouseIdentity identity,
PalletOperationRequests.RelabelSources request) {
RelabelApplication app = relabelApplications.findAnyByRelabelNo(request.relabelNo());
requireRelabelWork(identity, app);
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()));
Map<Long, List<RelabelItem>> byBalance = new LinkedHashMap<>();
for (RelabelItem item : items) {
InventoryPosition anchor = positions.selectById(item.getSourceInventoryPositionId());
if (anchor == null || !Objects.equals(anchor.getCustomerId(), app.getCustomerId())
|| !Objects.equals(anchor.getOverseasWarehouseId(), app.getOverseasWarehouseId()))
throw conflict("换标单库存来源缺失,请核对原入仓单");
byBalance.computeIfAbsent(anchor.getBalanceId(), ignored -> new ArrayList<>()).add(item);
}
List<PalletOperationResponses.RelabelSource> result = new ArrayList<>();
byBalance.forEach((balanceId, group) -> {
for (PalletInventoryRow row : mapper.relabelSources(app.getCustomerId(), app.getOverseasWarehouseId(), balanceId)) {
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(),
value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages())
+ value(row.getPickedPackages()),
value(row.getAvailablePackages()) + Math.min(ownHold, value(row.getHoldPackages())),
group.stream().map(RelabelItem::getCartonMark).filter(mark -> !boundMarks.contains(mark)).toList()));
}
});
return result;
}
public PalletOperationResponses.PrintablePallet createRelabelPallet(PdaWarehouseIdentity identity,
PalletOperationRequests.NewRelabelPallet request) {
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认,不能生成新托盘");
}
requireRelabelWork(identity, application);
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
@@ -153,9 +182,7 @@ public class PdaPalletOperationService {
if (replay != null) return replay;
String relabelNo = required(request.relabelNo(), "换标单号不能为空");
RelabelApplication application = relabelApplications.findAnyByRelabelNo(relabelNo);
if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus())) {
throw conflict("换标单尚未完成客户确认,不能执行拆托");
}
requireRelabelWork(identity, application);
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("换标单不属于 Token 冻结的海外仓");
}
@@ -177,29 +204,61 @@ 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
|| !Objects.equals(actual.getCustomerId(), application.getCustomerId())
|| !Objects.equals(actual.getPalletId(), source.palletId())
|| !Objects.equals(actual.getOverseasWarehouseId(), application.getOverseasWarehouseId())
|| value(actual.getVersion()) != request.actualSource().expectedPositionVersion()))
throw conflict("实际来源托盘库存已变化,请刷新后确认");
if (actual != null && application.getOutboundOrderNo() != null && !application.getOutboundOrderNo().isBlank())
throw conflict("已生成出库单,请先处理出库单,不能重新分配冻结来源");
if (actual != null && marks.stream().anyMatch(boundRelabelMarks(application)::contains))
throw conflict("本批包含本轮已组托的换标箱,请刷新后选择未组托的箱");
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()))
throw conflict("请先完成本箱全部换标作业,再进行组托");
}
}
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
InventoryPosition position = positions.selectById(item.getSourceInventoryPositionId());
if (position == null || !Objects.equals(position.getCustomerId(), application.getCustomerId())
|| !Objects.equals(position.getPalletId(), source.palletId())
|| (actual == null && !Objects.equals(position.getPalletId(), source.palletId()))
|| (actual != null && (!Objects.equals(position.getBalanceId(), actual.getBalanceId())
|| !Objects.equals(position.getBatchNo(), actual.getBatchNo())))
|| !Objects.equals(position.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId()))) {
throw conflict("扫描的原托盘与换标箱唛实际库存位置不一致");
throw conflict("来源托盘必须属于本次拆托的原入仓单和库存批次");
}
packagesByPosition.merge(position.getId(), item.getNotifiedPackageCount().longValue(), Long::sum);
itemsByPosition.computeIfAbsent(position.getId(), ignored -> new ArrayList<>()).add(item);
anchors.put(position.getId(), position);
Long sourcePosition = actual == null ? position.getId() : actual.getId();
packagesByPosition.merge(sourcePosition, item.getNotifiedPackageCount().longValue(), Long::sum);
itemsByPosition.computeIfAbsent(sourcePosition, ignored -> new ArrayList<>()).add(item);
}
if (actual != null && request.actualSource().packages() != packagesByPosition.get(actual.getId()))
throw conflict("拆出箱数必须等于本次确认绑定的换标箱数");
List<PlannedMove> moves = packagesByPosition.entrySet().stream().map(entry -> {
InventoryPosition position = positions.selectById(entry.getKey());
if (value(position.getHoldPackages()) < entry.getValue()) throw conflict("换标冻结库存箱数不足");
long own = selected.stream().filter(item -> Objects.equals(item.getSourceInventoryPositionId(), position.getId()))
.mapToLong(item -> value(item.getNotifiedPackageCount())).sum();
if (actual == null ? value(position.getHoldPackages()) < entry.getValue()
: value(position.getHoldPackages()) < own || value(position.getAvailablePackages()) < entry.getValue() - own)
throw conflict("来源托盘可拆箱数不足,不能占用其他单据冻结或已分配库存");
return new PlannedMove(entry.getKey(), entry.getValue(), value(position.getVersion()), source.palletId(),
source.palletCode(), target.palletId(), target.palletCode(), target.locationId(),
locationCode(identity.overseasWarehouseId(), target.locationId()), InventoryBucket.HOLD);
}).toList();
requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target));
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
Plan base = plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false);
Plan relabelPlan = new Plan(base.pallets(), base.moves(), base.changes(),
new RelabelMutation(application, itemsByPosition));
new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors));
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
? "" : ":" + request.reason().trim());
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
@@ -288,6 +347,10 @@ public class PdaPalletOperationService {
throw conflict("库存所在托盘已变化,请重新扫码");
}
if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码");
if (plan.relabelMutation() != null && !plan.relabelMutation().anchors().isEmpty()) {
reconcileRelabelHold(identity, operation, plan.relabelMutation(), move, key);
locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId());
}
if (move.bucket() == InventoryBucket.HOLD) requireHeld(locked, move.packages());
else requireMovable(locked, move.packages());
customers.add(locked.getCustomerId());
@@ -296,6 +359,8 @@ public class PdaPalletOperationService {
}
if (plan.relabelMutation() != null) relabelPalletMutations.moveSelectedItems(
plan.relabelMutation().application(), plan.relabelMutation().itemsByPosition(), relabelTargets);
if (plan.relabelMutation() != null)
recordRelabelBinding(identity, plan.relabelMutation(), relabelTargets);
for (PalletChange change : plan.changes()) {
long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId());
boolean shouldBeEmpty = "IDLE".equals(change.targetStatus());
@@ -320,7 +385,8 @@ public class PdaPalletOperationService {
private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) {
RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo());
if (locked == null || !"READY_FOR_OUTBOUND".equals(locked.getStatus())
if (locked != null) requireUnallocatedRelabel(locked);
if (locked == null || !Objects.equals(locked.getStatus(), mutation.application().getStatus())
|| !Objects.equals(locked.getCustomerId(), mutation.application().getCustomerId())
|| !Objects.equals(locked.getOverseasWarehouseId(), String.valueOf(warehouseId))
|| !Objects.equals(locked.getOutboundOrderNo(), mutation.application().getOutboundOrderNo())) {
@@ -328,6 +394,52 @@ public class PdaPalletOperationService {
}
}
/** Redistributes only this application's hold within the same inbound balance; physical totals stay unchanged. */
private void reconcileRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation,
RelabelMutation mutation, PlannedMove move, String key) {
requireRelabelWork(identity, mutation.application());
List<RelabelItem> selected = mutation.itemsByPosition().get(move.positionId());
Set<String> alreadyBound = boundRelabelMarks(mutation.application());
if (selected.stream().anyMatch(item -> alreadyBound.contains(item.getCartonMark())))
throw conflict("换标箱已在本轮组托,请刷新后重试");
requireRelabelDestination(identity.overseasWarehouseId(), selected, physical.resolve(move.targetPalletCode()));
Map<Long, Long> release = new LinkedHashMap<>();
for (RelabelItem item : selected) {
RelabelItem current = relabelItems.selectById(item.getId());
if (current == null || !Objects.equals(current.getSourceInventoryPositionId(), item.getSourceInventoryPositionId())
|| !Objects.equals(current.getNotifiedPackageCount(), item.getNotifiedPackageCount()))
throw conflict("换标箱的绑定已变化,请刷新后重试");
if (!Objects.equals(item.getSourceInventoryPositionId(), move.positionId()))
release.merge(item.getSourceInventoryPositionId(), (long) value(item.getNotifiedPackageCount()), Long::sum);
}
InventoryPosition actual = positions.selectById(move.positionId());
long required = release.values().stream().mapToLong(Long::longValue).sum();
if (value(actual.getAvailablePackages()) < required) throw conflict("实际来源托盘可用箱数不足");
InventoryBalance balance = balances.selectById(actual.getBalanceId());
for (var entry : release.entrySet()) {
PalletInventoryRow locked = mapper.lockPosition(mutation.application().getOverseasWarehouseId(), entry.getKey());
InventoryPosition snapshot = mutation.anchors().get(entry.getKey());
if (locked == null || value(locked.getVersion()) != value(snapshot.getVersion())
|| !Objects.equals(locked.getBalanceId(), actual.getBalanceId())
|| value(locked.getHoldPackages()) < entry.getValue())
throw conflict("原冻结库存已变化,请刷新后重试");
InventoryPosition anchor = positions.selectById(entry.getKey());
adjustRelabelHold(identity, operation, balance, anchor, -entry.getValue(), key + ":RELEASE:" + anchor.getId());
}
if (required > 0) adjustRelabelHold(identity, operation, balance, actual, required, key + ":FREEZE:" + actual.getId());
}
private void adjustRelabelHold(PdaWarehouseIdentity identity, PdaPalletOperation operation,
InventoryBalance balance, InventoryPosition position, long delta, String key) {
long before = value(position.getHoldPackages());
position.setHoldPackages(before + delta);
position.setAvailablePackages(value(position.getAvailablePackages()) - delta);
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(LocalDateTime.now());
positions.updateById(position);
ledger(identity, operation, balance, position, key, before, delta, before + delta,
position.getPalletNo(), position.getPalletNo(), "现场按原入仓单确认拆托来源:冻结桶调整,可用桶反向调整,实物总数不变");
}
private InventoryPosition moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation,
PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) {
InventoryPosition source = positions.selectById(sourceRow.getPositionId());
@@ -458,6 +570,89 @@ public class PdaPalletOperationService {
if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘");
}
@jakarta.annotation.Resource
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper relabelWorkTasks;
@jakarta.annotation.Resource
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper relabelWorkFacts;
@jakarta.annotation.Resource
private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper relabelWorkHistory;
private Set<String> boundRelabelMarks(RelabelApplication app) {
var task = relabelWorkTasks.find(app.getCustomerId(), app.getId());
if (task == null) return Set.of();
String round = Objects.toString(task.getReviewedAt(), "INITIAL");
Set<String> marks = new HashSet<>();
var records = relabelWorkHistory.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory>()
.eq("customer_id", app.getCustomerId()).eq("task_id", task.getId()).eq("action", "PALLET_BIND"));
for (var record : records) {
try {
var data = objectMapper.readTree(record.getDetailJson());
if (round.equals(data.path("round").asText())) data.path("cartonMarks").forEach(mark -> marks.add(mark.asText()));
} catch (JsonProcessingException failure) { throw conflict("换标组托记录无法读取,请核对作业历史"); }
}
return marks;
}
private void recordRelabelBinding(PdaWarehouseIdentity identity, RelabelMutation mutation,
Map<Long, InventoryPosition> targets) {
var app = mutation.application();
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", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(),
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList())));
relabelWorkHistory.insert(record);
}
private void requireRelabelWork(PdaWarehouseIdentity identity, RelabelApplication application) {
if (application == null || !Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(application.getStatus()))
throw conflict("当前换标状态不能组托");
if (!Objects.equals(application.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId())))
throw conflict("换标单不属于当前海外仓");
requireUnallocatedRelabel(application);
if ("PROCESSING".equals(application.getStatus())) {
var task = relabelWorkTasks.find(application.getCustomerId(), application.getId());
if (task == null || !"PROCESSING".equals(task.getStatus()) || !identity.operatorKey().equals(task.getOperatorKey()))
throw conflict("请先认领当前换标任务");
}
}
private void requireUnallocatedRelabel(RelabelApplication application) {
String fulfillment = application.getFulfillmentStatus();
if (org.springframework.util.StringUtils.hasText(application.getOutboundOrderNo())
|| (org.springframework.util.StringUtils.hasText(fulfillment)
&& !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(fulfillment)))
throw conflict("换标单已进入排车或出库流程,请先解除占用后再改绑托盘");
}
private void requireRelabelDestination(long warehouseId, List<RelabelItem> selected,
PalletPhysicalGateway.PalletReference target) {
Set<String> destinations = new HashSet<>();
selected.forEach(item -> destinations.add(text(item.getDestinationFbaWarehouseCode())));
if (destinations.size() != 1 || destinations.contains("")) throw conflict("请按已维护的新目的仓分批组托");
for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), target.palletId())) {
var bound = relabelItems.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<RelabelItem>()
.eq("customer_id", row.getCustomerId()).eq("source_inventory_position_id", row.getPositionId()));
long held = 0;
for (RelabelItem item : bound) {
var app = relabelApplications.selectById(item.getApplicationId());
if (app != null && Set.of("PROCESSING", "CUSTOMER_CONFIRM_PENDING", "READY_FOR_OUTBOUND").contains(app.getStatus())) {
held += value(item.getNotifiedPackageCount()); destinations.add(text(item.getDestinationFbaWarehouseCode()));
}
}
if (value(row.getAvailablePackages()) > 0 || value(row.getAllocatedPackages()) > 0
|| value(row.getPickedPackages()) > 0 || value(row.getHoldPackages()) > held)
destinations.add(text(row.getDestinationWarehouseCode()));
}
if (destinations.size() != 1) throw conflict("目标托盘内货物与本次换标的新目的仓不同,不能合托");
}
private Map<String, PalletPhysicalGateway.PalletReference> resolvePallets(long warehouseId,
PalletOperationRequests.PalletExpectation first, List<PalletOperationRequests.PalletExpectation> rest) {
List<PalletOperationRequests.PalletExpectation> values = new ArrayList<>(); values.add(first); values.addAll(rest);
@@ -658,6 +853,7 @@ public class PdaPalletOperationService {
List<PlannedMove> moves, List<PalletChange> changes,
RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsByPosition) { }
Map<Long, List<RelabelItem>> itemsByPosition,
Map<Long, InventoryPosition> anchors) { }
private enum InventoryBucket { AVAILABLE, HOLD }
}