Merge remote-tracking branch 'origin/test' into test

# Conflicts:
#	deploy/sql/README.md
#	deploy/sql/海外区域统一增量-MySQL8.sql
This commit is contained in:
2026-10-16 15:44:59 +08:00
61 changed files with 1246 additions and 125 deletions
@@ -14,6 +14,26 @@ import java.util.List;
@Mapper
public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
@Select("""
SELECT EXISTS(
SELECT 1 FROM ll_inbound_plan_carton c
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
JOIN ll_inbound_order i ON i.customer_id=o.customer_id AND i.inbound_code=o.inbound_code
WHERE c.customer_id=#{customerId} AND i.id=#{inboundOrderId}
AND o.overseas_warehouse_id=#{warehouseId} AND c.carton_mark=#{cartonMark}
UNION ALL
SELECT 1 FROM ll_warehouse_transfer_carton c
JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id
WHERE c.customer_id=#{customerId} AND t.inbound_order_id=#{inboundOrderId}
AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED'
AND (c.carton_mark=#{cartonMark} OR c.customer_carton_mark=#{cartonMark})
)
""")
boolean cartonBelongsToInbound(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId,
@Param("inboundOrderId") Long inboundOrderId,
@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " +
"AND overseas_warehouse_id=#{overseasWarehouseId} " +
"AND destination_warehouse_code=#{destinationWarehouseCode} " +
@@ -34,6 +34,8 @@ public final class PalletOperationRequests {
@NotEmpty @Size(max = 20) List<@Valid TargetMove> targets,
@Size(max = 500) String reason) { }
public record MeasuredCartonBinding(@NotBlank @Size(max=255) String cartonMark,
@NotBlank @Size(max=255) String sourceCartonMark) {}
/** source为空时表示退货箱没有来源托盘,现场仅扫描箱唛后绑定目标托盘;actualSource为现场确认的原入仓库存行。 */
public record RelabelDisassemble(@NotBlank @Size(max = 128) String idempotencyKey,
@NotBlank @Size(max = 64) String relabelNo,
@@ -41,10 +43,15 @@ public final class PalletOperationRequests {
@Valid PalletExpectation target,
@NotEmpty @Size(max = 500) List<@NotBlank @Size(max = 255) String> cartonMarks,
@Size(max = 500) String reason,
@Valid MoveLine actualSource) {
@Valid MoveLine actualSource,
@Size(max=500) List<@Valid MeasuredCartonBinding> measuredCartons) {
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
PalletExpectation target, List<String> cartonMarks, String reason, MoveLine actualSource) {
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, actualSource, null);
}
public RelabelDisassemble(String idempotencyKey, String relabelNo, PalletExpectation source,
PalletExpectation target, List<String> cartonMarks, String reason) {
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null);
this(idempotencyKey, relabelNo, source, target, cartonMarks, reason, null, null);
}
}
@@ -68,6 +68,7 @@ public class PdaPalletOperationService {
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private RelabelItemMapper relabelItems;
@Resource private RelabelPalletMutationService relabelPalletMutations;
@Resource private com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService cartonMeasurements;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ReturnInventoryBalanceMapper returnBalances;
@@ -307,7 +308,7 @@ public class PdaPalletOperationService {
Set<String> bound = boundRelabelMarks(application);
if (!marks.stream().allMatch(bound::contains)) {
if (marks.stream().anyMatch(bound::contains)) throw conflict("本批包含已绑定箱唛,请刷新后重试");
recordRelabelBinding(identity, new RelabelMutation(application, byPosition, Map.of()), retained);
recordRelabelBinding(identity, new RelabelMutation(application, byPosition, Map.of(), request.measuredCartons()), retained);
}
var result = new PalletOperationResponses.Operation(IdWorker.getId(), "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(),
"COMPLETED", false, LocalDateTime.now(), List.of(content(identity.overseasWarehouseId(), source)));
@@ -351,7 +352,7 @@ public class PdaPalletOperationService {
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, actual == null ? Map.of() : anchors));
new RelabelMutation(application, itemsByPosition, actual == null ? Map.of() : anchors, request.measuredCartons()));
String reason = "换标拆托" + (request.reason() == null || request.reason().isBlank()
? "" : ":" + request.reason().trim());
return execute(identity, "RELABEL_PALLET_DISASSEMBLE", request.idempotencyKey(), request,
@@ -840,7 +841,12 @@ public class PdaPalletOperationService {
record.setFromStatus(task.getStatus()); record.setToStatus(task.getStatus());
record.setOperatorKey(identity.operatorKey()); record.setOperatorName(identity.operatorName());
record.setCreatedAt(LocalDateTime.now());
var measured = cartonMeasurements.prepare(app,
mutation.itemsByPosition().values().stream().flatMap(List::stream).toList(),
mutation.itemsByPosition().keySet().stream().map(positions::selectById).filter(Objects::nonNull).findFirst().orElseThrow().getPalletId(),
targets.values().iterator().next().getPalletId(), targets.values().iterator().next().getPalletNo(), mutation.measuredCartons());
record.setDetailJson(write(Map.of("round", Objects.toString(task.getReviewedAt(), "INITIAL"),
"measuredCartons", measured,
"cartonMarks", mutation.itemsByPosition().values().stream().flatMap(List::stream).map(RelabelItem::getCartonMark).toList(),
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(),
"sourcePallets", mutation.itemsByPosition().keySet().stream().map(positions::selectById)
@@ -1122,6 +1128,11 @@ public class PdaPalletOperationService {
RelabelMutation relabelMutation) { }
private record RelabelMutation(RelabelApplication application,
Map<Long, List<RelabelItem>> itemsByPosition,
Map<Long, InventoryPosition> anchors) { }
Map<Long, InventoryPosition> anchors,
List<com.baiyue.lastleg.palletops.dto.PalletOperationRequests.MeasuredCartonBinding> measuredCartons) {
RelabelMutation(RelabelApplication application, Map<Long,List<RelabelItem>> itemsByPosition, Map<Long,InventoryPosition> anchors) {
this(application,itemsByPosition,anchors,null);
}
}
private enum InventoryBucket { AVAILABLE, HOLD }
}
@@ -9,6 +9,23 @@ public interface RelabelPalletMeasurementMapper {
record Stock(Long id, Long palletId, String palletNo, long packages, long version) {}
record Original(Long palletId, String palletNo, int packages, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg, BigDecimal volumeCbm) {}
record CartonSpec(Long palletId, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal grossWeightKg, long cartons) {}
record Carton(Long id, String cartonMark, String customerCartonMark, Long palletId, String palletCode,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg) {}
@Select("SELECT c.id,c.carton_mark cartonMark,c.customer_carton_mark customerCartonMark,p.warehouse_pallet_id palletId,p.pallet_no palletCode," +
"c.length_cm lengthCm,c.width_cm widthCm,c.height_cm heightCm,c.gross_weight_kg grossWeightKg " +
"FROM ll_warehouse_transfer_carton c JOIN ll_warehouse_transfer_pallet p " +
"ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id AND p.transfer_order_id=c.transfer_order_id " +
"WHERE c.customer_id=#{customerId} AND c.transfer_order_id=#{transferId} AND c.status='COUNTED' AND p.status='COMPLETED' ORDER BY c.id")
List<Carton> cartons(@Param("customerId") String customerId, @Param("transferId") Long transferId);
@Select("SELECT p.warehouse_pallet_id palletId,c.length_cm lengthCm,c.width_cm widthCm,c.height_cm heightCm," +
"c.gross_weight_kg grossWeightKg,COUNT(*) cartons FROM ll_warehouse_transfer_carton c " +
"JOIN ll_warehouse_transfer_pallet p ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id AND p.transfer_order_id=c.transfer_order_id " +
"WHERE c.customer_id=#{customerId} AND c.transfer_order_id=#{transferId} AND c.status='COUNTED' AND p.status='COMPLETED' " +
"GROUP BY p.warehouse_pallet_id,c.length_cm,c.width_cm,c.height_cm,c.gross_weight_kg " +
"ORDER BY p.warehouse_pallet_id,c.length_cm,c.width_cm,c.height_cm,c.gross_weight_kg")
List<CartonSpec> cartonSpecs(@Param("customerId") String customerId, @Param("transferId") Long transferId);
@Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo}")
Long transfer(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT id,pallet_id palletId,pallet_no palletNo," +
@@ -80,6 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler {
READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED");
private static final Set<String> DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP");
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@Resource private RelabelStatusHistoryMapper histories;
@@ -417,13 +418,13 @@ public class RelabelApplicationService implements WalletResultHandler {
}
if (carton == null || !customerId.equals(carton.getCustomerId())
|| !request.operationWarehouseId().trim().equals(carton.getOverseasWarehouseId())) {
throw conflict("所选退货箱不属于当前客户和操作仓");
throw conflict("当前箱子不存在或不属于当前订单");
}
if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) {
throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标");
}
if (!carton.getCartonMark().equals(item.cartonMark().trim()))
throw conflict("来源退货箱号与选择的库存不一致");
throw conflict("当前箱子不存在或不属于当前订单");
com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton);
final long sourceReturnCartonId = carton.getId();
Map<String, Long> availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream()
@@ -474,6 +475,10 @@ public class RelabelApplicationService implements WalletResultHandler {
|| !Objects.equals(balance.getInboundOrderId(), item.sourceInboundOrderId())) {
throw conflict("Selected inventory does not belong to this customer, operation warehouse and inbound order");
}
if (!inventoryBalances.cartonBelongsToInbound(customerId, request.operationWarehouseId().trim(),
item.sourceInboundOrderId(), item.cartonMark().trim())) {
throw conflict("当前箱子不存在或不属于当前订单");
}
if (value(position.getAvailablePackages()) < item.notifiedPackageCount()) {
throw conflict("Available inventory changed for inbound " + balance.getInboundOrderNo());
}
@@ -482,7 +487,7 @@ public class RelabelApplicationService implements WalletResultHandler {
LocalDateTime now = LocalDateTime.now();
long applicationId = IdWorker.getId();
String operationWarehouseId = request.operationWarehouseId().trim();
String relabelNo = orderNoGenerator.next("RELABEL", operationWarehouseId, applicationId);
String relabelNo = compactNumbers.next("TB", customerId);
BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO);
boolean requiresWallet = chargeAmount.signum() > 0;
int holdIndex = 0;
@@ -1362,7 +1367,7 @@ public class RelabelApplicationService implements WalletResultHandler {
}
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
order.setOrderNo(orderNoGenerator.next("RELABEL", warehouseId, order.getId()));
order.setOrderNo(relabelNo);
order.setCustomerId(customerId); order.setOrderType("RELABEL_APPLICATION");
order.setInboundCode(null); order.setSourceRegion("CUSTOMER");
order.setSourceInboundId(relabelNo + ":" + warehouseId);
@@ -0,0 +1,94 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelPalletMeasurementMapper;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** Replays saved physical box bindings; dimensions always come from measured source boxes. */
@Service
public class RelabelCartonMeasurementService {
@Resource private RelabelPalletMeasurementMapper mapper;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private ObjectMapper json;
public record Move(String cartonMark, String sourceCartonMark, Long targetPalletId, String targetPalletCode) {}
public List<RelabelPalletMeasurementMapper.Carton> current(RelabelApplication app) {
Long transfer = mapper.transfer(app.getCustomerId(), app.getRelabelNo());
if (transfer == null) return List.of();
var boxes = new LinkedHashMap<String, RelabelPalletMeasurementMapper.Carton>();
for (var box : mapper.cartons(app.getCustomerId(), transfer)) boxes.put(box.cartonMark(), box);
if (boxes.isEmpty()) return List.of();
for (var history : records(app)) if ("PALLET_BIND".equals(history.getAction())) {
try {
for (var node : json.readTree(history.getDetailJson()).path("measuredCartons")) {
var move = json.treeToValue(node, Move.class);
var box = boxes.get(move.sourceCartonMark());
if (box == null) throw conflict("换标箱测量来源无法追溯");
boxes.put(box.cartonMark(), new RelabelPalletMeasurementMapper.Carton(box.id(), box.cartonMark(), box.customerCartonMark(),
move.targetPalletId(), move.targetPalletCode(), box.lengthCm(), box.widthCm(), box.heightCm(), box.grossWeightKg()));
}
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("换标箱绑定记录无法读取"); }
}
return List.copyOf(boxes.values());
}
public List<Move> prepare(RelabelApplication app, List<RelabelItem> items, Long sourcePallet,
Long targetPallet, String targetCode, List<com.baiyue.lastleg.palletops.dto.PalletOperationRequests.MeasuredCartonBinding> requested) {
var boxes = current(app);
if (boxes.isEmpty()) return List.of();
var available = new ArrayList<>(boxes.stream().filter(box -> sourcePallet.equals(box.palletId())).toList());
var mappings = new HashMap<String,String>();
// A rebind retains the exact physical box selected at its first binding.
for (var history : records(app)) if ("PALLET_BIND".equals(history.getAction())) {
try { for (var node : json.readTree(history.getDetailJson()).path("measuredCartons"))
mappings.put(node.path("cartonMark").asText(), node.path("sourceCartonMark").asText());
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("换标箱绑定记录无法读取"); }
}
var explicit = new HashMap<String,String>();
var selectedMarks = items.stream().map(RelabelItem::getCartonMark).toList();
available.removeIf(box -> mappings.entrySet().stream().anyMatch(entry -> box.cartonMark().equals(entry.getValue()) && !selectedMarks.contains(entry.getKey())));
for (var binding : requested == null ? List.<com.baiyue.lastleg.palletops.dto.PalletOperationRequests.MeasuredCartonBinding>of() : requested) {
if (binding == null || binding.cartonMark() == null || binding.sourceCartonMark() == null
|| items.stream().noneMatch(item -> binding.cartonMark().equals(item.getCartonMark()))
|| explicit.put(binding.cartonMark(),binding.sourceCartonMark()) != null)
throw conflict("实测箱选择无效或重复");
}
var result = new ArrayList<Move>();
for (var item : items) {
if (!Integer.valueOf(1).equals(item.getNotifiedPackageCount())) throw conflict("按箱测量的换标明细必须对应一个实测箱");
String prior = mappings.get(item.getCartonMark()), chosen = explicit.get(item.getCartonMark());
if (prior != null && chosen != null && !prior.equals(chosen)) throw conflict("改绑托盘不能替换已绑定的实测箱");
if (chosen == null) chosen = prior;
final String key = chosen;
var matched = available.stream().filter(box -> key != null ? key.equals(box.cartonMark())
: item.getCartonMark().equals(box.cartonMark()) || item.getCartonMark().equals(box.customerCartonMark())).toList();
RelabelPalletMeasurementMapper.Carton box = matched.size() == 1 ? matched.get(0) : null;
// Identical measurements may be allocated without a further scan; mixed specifications require an explicit choice.
if (box == null && chosen == null && !available.isEmpty() && available.stream().allMatch(b -> sameSpec(available.get(0), b))) box = available.get(0);
if (box == null) throw conflict("请选择本次换标对应的来源实测箱,箱规不同不能自动分摊体积和重量");
if (!positive(box.lengthCm()) || !positive(box.widthCm()) || !positive(box.heightCm()) || !positive(box.grossWeightKg()))
throw conflict("来源箱缺少有效实测尺寸或重量,请先完成箱测量");
available.remove(box);
result.add(new Move(item.getCartonMark(),box.cartonMark(),targetPallet,targetCode));
}
return result;
}
private boolean sameSpec(RelabelPalletMeasurementMapper.Carton a, RelabelPalletMeasurementMapper.Carton b) {
return equal(a.lengthCm(),b.lengthCm()) && equal(a.widthCm(),b.widthCm()) && equal(a.heightCm(),b.heightCm()) && equal(a.grossWeightKg(),b.grossWeightKg());
}
private boolean equal(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; }
private boolean positive(java.math.BigDecimal value) { return value != null && value.signum() > 0; }
private List<PdaRelabelHistory> records(RelabelApplication app) {
return histories.selectList(new QueryWrapper<PdaRelabelHistory>().eq("customer_id",app.getCustomerId())
.eq("application_id",app.getId()).orderByAsc("created_at","id"));
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
}
@@ -27,6 +27,7 @@ public class RelabelPalletMeasurementService {
@Resource private PdaRelabelHistoryMapper histories;
@Resource private PdaRelabelTaskMapper tasks;
@Resource private ObjectMapper json;
@Resource private RelabelCartonMeasurementService cartonMeasurements;
public record Measure(@NotBlank @Size(max=64) String relabelNo, @NotBlank @Size(max=128) String palletCode,
@NotBlank @Size(max=128) String idempotencyKey, @NotBlank @Size(max=32768) String stockVersion,
@NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal lengthCm,
@@ -35,7 +36,15 @@ public class RelabelPalletMeasurementService {
@NotNull @DecimalMin("0.01") @Digits(integer=9,fraction=3) BigDecimal grossWeightKg) {}
public record Measurement(Long palletId, String palletCode, long packages, String stockVersion,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg,
BigDecimal volumeCbm, boolean inherited) {}
BigDecimal volumeCbm, boolean inherited, List<RelabelPalletMeasurementMapper.CartonSpec> sourceCartonSpecs,
boolean cartonAggregated, List<RelabelPalletMeasurementMapper.Carton> cartons) {
public Measurement(Long palletId, String palletCode, long packages, String stockVersion,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm, BigDecimal grossWeightKg,
BigDecimal volumeCbm, boolean inherited) {
this(palletId, palletCode, packages, stockVersion, lengthCm, widthCm, heightCm,
grossWeightKg, volumeCbm, inherited, List.of(), false, List.of());
}
}
public List<Measurement> list(PdaWarehouseIdentity identity, String relabelNo) {
return snapshots(scoped(identity, relabelNo, false));
@@ -70,6 +79,7 @@ public class RelabelPalletMeasurementService {
var current = snapshots(app).stream().filter(p -> p.palletCode().equals(request.palletCode())).findFirst()
.orElseThrow(() -> conflict("托盘不属于本换标单或已经清空"));
if (!current.stockVersion().equals(request.stockVersion())) throw conflict("托盘库存已变化,请刷新后重新测量");
if (current.cartonAggregated()) throw conflict("本托盘按实测箱自动汇总体积和重量,无需确认整托实测");
var measured = new Measurement(current.palletId(), current.palletCode(), current.packages(), current.stockVersion(),
request.lengthCm(), request.widthCm(), request.heightCm(), request.grossWeightKg(),
request.lengthCm().multiply(request.widthCm()).multiply(request.heightCm()).movePointLeft(6).setScale(6, RoundingMode.HALF_UP), false);
@@ -85,23 +95,40 @@ public class RelabelPalletMeasurementService {
if (transferId == null) return List.of(); // Other relabel sources retain their own physical measurement contract.
var stock = mapper.stock(app.getCustomerId(), app.getOverseasWarehouseId());
var originals = mapper.originals(app.getCustomerId(), transferId);
var cartonSpecs = mapper.cartonSpecs(app.getCustomerId(), transferId);
var involved = new HashSet<Long>(); originals.forEach(p -> involved.add(p.palletId()));
var positions = items.selectList(new QueryWrapper<com.baiyue.lastleg.relabel.entity.RelabelItem>()
.eq("customer_id", app.getCustomerId()).eq("application_id", app.getId()));
var positionIds = positions.stream().map(i -> i.getSourceInventoryPositionId()).toList();
stock.stream().filter(p -> positionIds.contains(p.id())).forEach(p -> involved.add(p.palletId()));
var records = histories(app);
var currentBoxes = cartonMeasurements.current(app);
var codes = new HashSet<String>();
for (var h : records) if ("PALLET_BIND".equals(h.getAction())) {
tree(h).path("sourcePallets").forEach(p -> codes.add(p.asText()));
tree(h).path("targetPallets").forEach(p -> codes.add(p.asText()));
}
stock.stream().filter(p -> codes.contains(p.palletNo())).forEach(p -> involved.add(p.palletId()));
currentBoxes.forEach(box -> involved.add(box.palletId()));
var result = new ArrayList<Measurement>();
for (Long pallet : involved.stream().filter(Objects::nonNull).sorted().toList()) {
var rows = stock.stream().filter(p -> pallet.equals(p.palletId()) && p.packages() > 0).toList();
long count = rows.stream().mapToLong(p -> p.packages()).sum(); if (count == 0) continue;
String version = rows.stream().map(p -> p.id()+":"+p.version()+":"+p.packages()).collect(java.util.stream.Collectors.joining(","));
if (!currentBoxes.isEmpty()) {
var boxes = currentBoxes.stream().filter(box -> pallet.equals(box.palletId())).toList();
boolean complete = boxes.size() == count && boxes.stream().allMatch(box -> positive(box.lengthCm())
&& positive(box.widthCm()) && positive(box.heightCm()) && positive(box.grossWeightKg()));
var specs = new LinkedHashMap<List<BigDecimal>,Long>();
if (complete) for (var box : boxes) specs.merge(List.of(box.lengthCm(),box.widthCm(),box.heightCm(),box.grossWeightKg()),1L,Long::sum);
var grouped = specs.entrySet().stream().map(entry -> new RelabelPalletMeasurementMapper.CartonSpec(pallet,
entry.getKey().get(0),entry.getKey().get(1),entry.getKey().get(2),entry.getKey().get(3),entry.getValue())).toList();
BigDecimal volume = complete ? boxes.stream().map(box -> box.lengthCm().multiply(box.widthCm()).multiply(box.heightCm()).movePointLeft(6))
.reduce(BigDecimal.ZERO,BigDecimal::add).setScale(6,RoundingMode.HALF_UP) : null;
BigDecimal weight = complete ? boxes.stream().map(RelabelPalletMeasurementMapper.Carton::grossWeightKg).reduce(BigDecimal.ZERO,BigDecimal::add) : null;
result.add(new Measurement(pallet,rows.get(0).palletNo(),count,version,null,null,null,weight,volume,true,grouped,true,boxes));
continue;
}
Measurement value = null;
for (var h : records) if ("PALLET_MEASURE".equals(h.getAction())) {
var measured = json.convertValue(tree(h).path("measurement"), Measurement.class);
@@ -110,7 +137,9 @@ public class RelabelPalletMeasurementService {
if (value == null && mapper.movements(app.getCustomerId(), pallet) == 0) {
var original = originals.stream().filter(p -> pallet.equals(p.palletId()) && p.packages() == count).findFirst().orElse(null);
if (original != null) value = new Measurement(pallet, rows.get(0).palletNo(), count, version,
original.lengthCm(), original.widthCm(), original.heightCm(), original.grossWeightKg(), original.volumeCbm(), true);
positiveOrNull(original.lengthCm()), positiveOrNull(original.widthCm()), positiveOrNull(original.heightCm()),
original.grossWeightKg(), original.volumeCbm(), true,
cartonSpecs.stream().filter(spec -> pallet.equals(spec.palletId())).toList(), false, List.of());
}
result.add(value == null ? new Measurement(pallet, rows.get(0).palletNo(), count, version, null,null,null,null,null,false) : value);
}
@@ -118,10 +147,28 @@ public class RelabelPalletMeasurementService {
}
public void requireMeasured(RelabelApplication app) {
for (var p : snapshots(app)) if (p.grossWeightKg() == null || p.volumeCbm() == null
|| p.lengthCm() == null || p.widthCm() == null || p.heightCm() == null
|| !(positive(p.lengthCm()) && positive(p.widthCm()) && positive(p.heightCm())) && !completeSourceCartons(p)
|| p.grossWeightKg().signum() <= 0 || p.volumeCbm().signum() <= 0)
throw conflict("托盘 " + p.palletCode() + " 拆合托后尚未确认实测尺寸和重量,请在 PDA 补测(含原托盘剩余货物)");
throw conflict("托盘 " + p.palletCode() + (p.cartonAggregated()
? " 实测箱数据不完整或与库存箱数不一致,请刷新核对来源箱"
: " 拆合托后尚未确认实测尺寸和重量,请在 PDA 补测(含原托盘剩余货物)"));
}
private boolean completeSourceCartons(Measurement p) {
if (!p.inherited() || p.sourceCartonSpecs() == null || p.sourceCartonSpecs().isEmpty()) return false;
long count = 0; BigDecimal volume = BigDecimal.ZERO, weight = BigDecimal.ZERO;
for (var spec : p.sourceCartonSpecs()) {
if (spec.cartons() <= 0 || !positive(spec.lengthCm()) || !positive(spec.widthCm())
|| !positive(spec.heightCm()) || !positive(spec.grossWeightKg())) return false;
count += spec.cartons();
var boxes = BigDecimal.valueOf(spec.cartons());
volume = volume.add(spec.lengthCm().multiply(spec.widthCm()).multiply(spec.heightCm()).movePointLeft(6).multiply(boxes));
weight = weight.add(spec.grossWeightKg().multiply(boxes));
}
return count == p.packages() && volume.setScale(6, RoundingMode.HALF_UP).compareTo(p.volumeCbm()) == 0
&& weight.compareTo(p.grossWeightKg()) == 0;
}
private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; }
private static BigDecimal positiveOrNull(BigDecimal value) { return positive(value) ? value : null; }
private List<PdaRelabelHistory> histories(RelabelApplication app) {
return histories.selectList(new QueryWrapper<PdaRelabelHistory>().eq("customer_id", app.getCustomerId())
.eq("application_id", app.getId()).orderByAsc("created_at","id"));
@@ -537,7 +537,7 @@ public class ReturnInventoryService {
String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId();
// One durable barcode identifies a physical parcel, irrespective of SKU count.
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP" + hash(List.of(receipt.getId())).substring(0, 24).toUpperCase(java.util.Locale.ROOT);
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP-" + Long.toString(receipt.getId(), 36).toUpperCase(java.util.Locale.ROOT);
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now();
@@ -602,7 +602,8 @@ public class ReturnInventoryService {
}
private String shortReturnMark(String returnNo, Long receiptId, String skuCode, long totalBoxes, long sequence) {
String prefix = hash(java.util.Arrays.asList(returnNo, receiptId, skuCode)).substring(0, 16).toUpperCase(java.util.Locale.ROOT);
String prefix = new java.math.BigInteger(hash(java.util.Arrays.asList(returnNo, receiptId, skuCode)).substring(0, 16), 16)
.toString(36).toUpperCase(java.util.Locale.ROOT);
return "RC" + prefix + "#" + totalBoxes + "-" + sequence;
}
@@ -1276,7 +1277,7 @@ public class ReturnInventoryService {
.map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList();
Map<List<String>, Long> totals = groups.stream().collect(java.util.stream.Collectors.groupingBy(group -> group, LinkedHashMap::new, java.util.stream.Collectors.counting()));
Map<List<String>, String> prefixes = new HashMap<>(); Map<List<String>, Long> sequences = new HashMap<>();
return groups.stream().map(group -> prefixes.computeIfAbsent(group, ignored -> "RC" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(java.util.Locale.ROOT))
return groups.stream().map(group -> prefixes.computeIfAbsent(group, ignored -> LastlegOrderNoGenerator.compactCartonMark(IdWorker.getId()))
+ "#" + totals.get(group) + "-" + sequences.merge(group, 1L, Long::sum)).toList();
}
private String hash(Object request) { try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -62,6 +62,7 @@ public class ReturnReceiptService {
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
public static final String COMPLETED = "COMPLETED";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private ReturnOrderMapper orders;
@Resource private ReturnOrderItemMapper orderItems;
@Resource private ReturnReceiptMapper receipts;
@@ -99,7 +100,7 @@ public class ReturnReceiptService {
order.setOverseasWarehouseId(text(request.overseasWarehouseId()));
order.setWarehouseContactName(text(request.warehouseContactName()));
order.setWarehouseContactPhone(text(request.warehouseContactPhone()));
order.setReturnNo(numbers.next("RETURN", order.getOverseasWarehouseId(), order.getId()));
order.setReturnNo(compactNumbers.next("TT", customerId));
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);
@@ -429,10 +430,10 @@ public class ReturnReceiptService {
totalQuantity = Math.addExact(totalQuantity, requested.quantity());
}
long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now();
String relabelNo = numbers.next("RELABEL", order.getOverseasWarehouseId(), appId);
String relabelNo = compactNumbers.next("TT", customerId);
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 = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(relabelNo);
tail.setCustomerId(customerId); tail.setOrderType("RELABEL_APPLICATION"); tail.setSourceRegion("CUSTOMER");
tail.setSourceInboundId("RETURN_RELABEL:" + relabelNo);
tail.setSourceEventId("RETURN_RELABEL:" + relabelNo);
@@ -612,9 +613,9 @@ public class ReturnReceiptService {
if ("PARCEL".equals(unitType) && request.totalBoxCount() != null) throw badRequest("包裹签收不填写箱数");
if (unitCode == null && "PALLET".equals(unitType)) unitCode = text(request.palletCode());
if (unitCode == null && "BOX".equals(unitType))
unitCode = "RC" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT) + "#1-1";
unitCode = LastlegOrderNoGenerator.compactCartonMark(IdWorker.getId());
if (unitCode == null && "PARCEL".equals(unitType)) {
unitCode = "PK" + hash(List.of(order.getReturnNo(), IdWorker.getId())).substring(0, 16).toUpperCase(java.util.Locale.ROOT);
unitCode = "PK-" + Long.toString(IdWorker.getId(), 36).toUpperCase(java.util.Locale.ROOT);
}
if (unitCode == null) throw badRequest(("PALLET".equals(unitType) ? "托盘码" : "包裹码") + "不能为空");
if ("PALLET".equals(unitType)
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.service;
import net.sourceforge.pinyin4j.PinyinHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/** Allocates suffix-free document numbers across all service instances. */
@Component
public class CompactBusinessNumberService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("yyMMddHHmmss", Locale.ROOT).withZone(ZONE);
private final JdbcTemplate jdbc;
private final ReturnOrderReferenceResolver customers;
public CompactBusinessNumberService(JdbcTemplate jdbc, ReturnOrderReferenceResolver customers) {
this.jdbc = jdbc;
this.customers = customers;
}
@Transactional
public String next(String prefix, String customerId) {
return allocate(prefix, initials(customers.customerName(customerId)), Instant.now());
}
String allocate(String prefix, String initials, Instant now) {
String key = prefix + "-" + initials;
long seconds = now.getEpochSecond();
// The row lock serializes identical prefixes/initials even across customers and JVMs.
jdbc.update("INSERT INTO ll_compact_number_clock(number_key,last_second) VALUES(?,LAST_INSERT_ID(?)) "
+ "ON DUPLICATE KEY UPDATE last_second=LAST_INSERT_ID(GREATEST(last_second+1,?))", key, seconds, seconds);
Long allocated = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (allocated == null || allocated < seconds) throw new IllegalStateException("业务编号生成失败");
return key + "-" + FORMAT.format(Instant.ofEpochSecond(allocated));
}
static String initials(String name) {
if (name == null || name.isBlank()) return "KH";
StringBuilder result = new StringBuilder();
boolean latinWord = false;
for (char c : name.trim().toCharArray()) {
if (result.length() >= 4) break;
if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) {
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(c);
if (pinyin != null && pinyin.length > 0) result.append(Character.toUpperCase(pinyin[0].charAt(0)));
latinWord = false;
} else if (c < 128 && Character.isLetterOrDigit(c)) {
if (!latinWord) result.append(Character.toUpperCase(c));
latinWord = true;
} else latinWord = false;
}
return result.isEmpty() ? "KH" : result.toString();
}
}
@@ -36,6 +36,7 @@ public class LastlegOrderNoGenerator {
public String next(String businessType, String warehouse, long uniqueId) {
String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT);
if ("RETURN_CARTON".equals(code)) return compactCartonMark(uniqueId);
String returnPrefix = switch (code) {
case "RETURN" -> "RT";
case "RETURN_RECEIPT" -> "RR";
@@ -65,6 +66,12 @@ public class LastlegOrderNoGenerator {
return "0".repeat(25 - encoded.length()) + encoded;
}
/** Preserve every snowflake ID bit; do not truncate or hash physical label identifiers. */
public static String compactCartonMark(long id) {
if (id <= 0) throw new IllegalArgumentException("Carton id must be positive");
return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT);
}
private String normalizeType(String value) {
String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT);
return switch (code) {
@@ -65,6 +65,10 @@ public class ReturnOrderReferenceResolver {
}
}
public String customerName(String id) {
return customerReference(id).tenantName();
}
private ReturnCustomerReferenceClient.Customer customerReference(String id) {
try {
var result = customers.customers(internalToken, 1, 30, null, "ACTIVE", Long.parseLong(id));
@@ -28,6 +28,14 @@ import java.util.List;
public class MerchantWarehouseTransferController {
@Resource private WarehouseTransferService service;
@GetMapping("/inventory-source-cartons")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')")
public ApiResponse<List<WarehouseTransferResponses.SourceCarton>> sourceCartons(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String operatingWarehouseId,
@RequestParam String inboundCode) {
return ApiResponse.success(service.sourceCartons(customer(user), operatingWarehouseId, inboundCode));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')")
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@@ -29,13 +29,13 @@ public final class WarehouseTransferRequests {
@Size(max = 32) String privateDestinationPostalCode,
@Size(max = 128) String privateDestinationContactName,
@Size(max = 64) String privateDestinationContactPhone,
@NotBlank @Size(max = 1000) String deliveryAddress,
@Size(max = 1000) String deliveryAddress,
@Size(max = 128) String pickupContactName,
@Size(max = 64) String pickupContactPhone,
@Positive int packageCount,
@NotNull @DecimalMin(value = "0.0") BigDecimal volumeCbm,
@NotBlank @Size(max = 64) String deliveryMethod,
@NotNull LocalDateTime estimatedArrivalAt,
@Size(max = 64) String deliveryMethod,
LocalDateTime estimatedArrivalAt,
LocalDateTime estimatedShipAt,
@Size(max = 128) String fbaShipmentNo,
@Size(max = 255) String trackingNo,
@@ -51,6 +51,14 @@ public final class WarehouseTransferRequests {
@NotEmpty @Size(max = 500) List<@Valid Item> items,
@Size(max = 1000) List<@Valid RelabelItem> relabelItems
) {
@jakarta.validation.constraints.AssertTrue(message="外来货物须填写入仓方式、地址和预计入仓时间;库存转运须选择FBA或私人仓")
public boolean isInboundModeValid() {
if (inboundCode != null && !inboundCode.isBlank())
return "FBA".equals(destinationType) || "PRIVATE".equals(destinationType)
|| destinationType == null && fbaWarehouseCode != null && !fbaWarehouseCode.isBlank();
return deliveryMethod != null && !deliveryMethod.isBlank() && deliveryAddress != null
&& !deliveryAddress.isBlank() && estimatedArrivalAt != null;
}
public Create(String inboundCode, List<Long> sourceLastlegOrderIds, String operatingWarehouseId,
String fbaWarehouseCode, String deliveryAddress, int packageCount, BigDecimal volumeCbm,
String deliveryMethod, LocalDateTime estimatedArrivalAt, String fbaShipmentNo,
@@ -6,6 +6,9 @@ import java.util.List;
public final class WarehouseTransferResponses {
private WarehouseTransferResponses() {}
public record SourceCarton(String cartonMark, String productNameZh, Long quantity,
String destinationWarehouseCode, String destinationWarehouseName, List<SourceCartonSku> items) {}
public record SourceCartonSku(String skuCode, Long quantity) {}
public record Item(Long id, String skuCode, String productNameZh, int packageCount, String shippingMark, Long quantity) {}
public record Carton(String cartonMark, String customerCartonMark, int cartonIndex, int totalCartons, String status,
Long transferItemId, Long transferPalletId, BigDecimal length, BigDecimal width, BigDecimal height,
@@ -3,6 +3,25 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper public interface InboundSnapshotMapper {
record SourceCarton(String cartonMark, String skuCode, String productNameZh, Long quantity,
String destinationWarehouseCode, String destinationWarehouseName, String skuItemsJson) {}
@Select("""
SELECT c.carton_mark cartonMark,c.sku_code skuCode,NULL productNameZh,NULL quantity,
c.destination_warehouse_code destinationWarehouseCode,c.destination_warehouse_name destinationWarehouseName,NULL skuItemsJson
FROM ll_inbound_plan_carton c JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND o.overseas_warehouse_id=#{warehouseId}
UNION ALL
SELECT c.carton_mark,i.sku_code,i.product_name_zh,c.counted_quantity,
t.destination_warehouse_code,t.destination_warehouse_name,c.sku_items_json
FROM ll_warehouse_transfer_carton c
JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id
JOIN ll_order o ON o.id=t.lastleg_order_id AND o.customer_id=t.customer_id
JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND i.transfer_order_id=t.id
WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED'
ORDER BY cartonMark
""")
java.util.List<SourceCarton> sourceCartons(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId, @Param("inboundCode") String inboundCode);
@Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND (inbound_code=#{code} OR source_inbound_id=#{code}) LIMIT 1")
Long findId(@Param("customerId") String customerId, @Param("code") String code);
@Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND source_inbound_id=#{sourceInboundId} LIMIT 1")
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.transfer.repository;
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 jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装仓储转运所需的退货来源查询,保留客户和操作仓隔离。 */
@Repository
public class TransferReturnSourceRepository {
@Resource private ReturnOrderMapper orders;
@Resource private ReturnInventoryCartonMapper cartons;
@Resource private ReturnInventoryCartonItemMapper items;
/** 按当前客户和退货单号查询来源单据。 */
public ReturnOrder findOrder(String customerId, String returnNo) {
return orders.findNo(customerId, returnNo);
}
/** 查询当前仓内已完成清点且可用的退货箱,不影响其他页面的历史库存查询。 */
public List<ReturnInventoryCarton> availableCartons(String customerId, String warehouseId, String returnNo) {
return cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus())
&& carton.getRelabelApplicationId() == null)
.toList();
}
/** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */
public List<ReturnInventoryCartonItem> cartonItems(List<Long> cartonIds) {
return cartonIds.isEmpty() ? List.of() : items.listCartons(cartonIds);
}
}
@@ -59,6 +59,10 @@ import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletItemMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferRelabelBindingMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferMeasurementPhotoMapper;
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.transfer.repository.TransferReturnSourceRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
@@ -88,6 +92,9 @@ import java.util.stream.Collectors;
@Service
public class WarehouseTransferService {
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
@Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers;
@Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries;
@@ -139,15 +146,59 @@ public class WarehouseTransferService {
@Resource private InventoryCommandService inventory;
@Resource private InventoryCommandMapper inventoryCommands;
@Resource private ObjectMapper objectMapper;
@Resource private TransferReturnSourceRepository returnSources;
/** 查询来源单据当前可用的箱唛及箱内 SKU,兼容普通入仓与退货库存。 */
public List<WarehouseTransferResponses.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
if (!StringUtils.hasText(warehouseId) || !StringUtils.hasText(inboundCode)) throw badRequest("请选择海外仓和库存来源单据");
String source = inboundCode.trim();
if (isReturnOrderSource(source)) {
String returnNo = returnNoFromSource(source);
ReturnOrder order = returnSources.findOrder(customerId, returnNo);
if (order == null || !warehouseId.trim().equals(order.getOverseasWarehouseId())) {
throw notFound("退货单不存在或不属于当前操作仓");
}
List<ReturnInventoryCarton> cartons = returnSources.availableCartons(customerId, warehouseId.trim(), returnNo);
List<Long> cartonIds = cartons.stream().map(ReturnInventoryCarton::getId).filter(Objects::nonNull).toList();
Map<Long, List<ReturnInventoryCartonItem>> items = cartonIds.isEmpty() ? Map.of()
: returnSources.cartonItems(cartonIds).stream().collect(Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId));
return cartons.stream().map(carton -> {
List<ReturnInventoryCartonItem> rows = items.getOrDefault(carton.getId(), List.of());
List<WarehouseTransferResponses.SourceCartonSku> contents = rows.stream()
.filter(item -> item.getQuantity() != null && item.getQuantity() > 0)
.map(item -> new WarehouseTransferResponses.SourceCartonSku(item.getSkuCode(), item.getQuantity())).toList();
return new WarehouseTransferResponses.SourceCarton(carton.getCartonMark(), null, carton.getTotalQuantity(),
carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), contents);
}).toList();
}
return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source).stream().map(row -> {
var carton = new WarehouseTransferCarton(); carton.setSkuItemsJson(row.skuItemsJson());
var contents = cartonContents(carton).stream().map(item -> new WarehouseTransferResponses.SourceCartonSku(item.skuCode(),item.quantity())).toList();
if (contents.isEmpty()) contents = List.of(new WarehouseTransferResponses.SourceCartonSku(row.skuCode(),row.quantity()));
return new WarehouseTransferResponses.SourceCarton(row.cartonMark(),row.productNameZh(),row.quantity(),
row.destinationWarehouseCode(),row.destinationWarehouseName(),contents);
}).toList();
}
private static LocalDateTime arrivalTime(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? null : request.estimatedArrivalAt();
}
private static String inboundMethod(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? "INVENTORY_TRANSFER" : request.deliveryMethod().trim();
}
private static String inboundAddress(WarehouseTransferRequests.Create request) {
return StringUtils.hasText(request.inboundCode()) ? "" : request.deliveryAddress().trim();
}
/** 创建转运草稿并关联来源单据,退货来源沿其关联尾程订单核对客户归属。 */
@Transactional
public WarehouseTransferResponses.Detail create(String customerId, String operatorId,
WarehouseTransferRequests.Create request) {
if (!request.isInboundModeValid()) throw badRequest("外来货物须填写入仓信息,库存转运须选择FBA仓或私人仓");
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count");
}
validateRelabelRequest(request);
validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
@@ -167,7 +218,7 @@ public class WarehouseTransferService {
}
LocalDateTime now = LocalDateTime.now();
long transferId = IdWorker.getId();
String transferNo = orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), transferId);
String transferNo = compactNumbers.next("ZY", customerId);
String requestedInboundCode = trim(request.inboundCode());
List<Long> selectedIds = request.sourceLastlegOrderIds() == null
? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList();
@@ -181,18 +232,35 @@ public class WarehouseTransferService {
List<LastlegOrder> selectedOrders;
if (existingInventoryTransfer) {
inboundCode = requestedInboundCode;
inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
if (isReturnOrderSource(inboundCode)) {
ReturnOrder returnOrder = returnSources.findOrder(customerId, returnNoFromSource(inboundCode));
if (returnOrder == null || !request.operatingWarehouseId().trim().equals(returnOrder.getOverseasWarehouseId())
|| returnOrder.getLastlegOrderId() == null) {
throw notFound("退货单不存在、操作仓不匹配或未关联尾程订单");
}
LastlegOrder returnOrderTail = orders.selectById(returnOrder.getLastlegOrderId());
if (returnOrderTail == null || !customerId.equals(returnOrderTail.getCustomerId())) {
throw notFound("退货单关联的尾程订单不存在");
}
inboundOrderId = StringUtils.hasText(returnOrderTail.getSourceInboundId())
? inboundSnapshots.findBySourceInboundId(customerId, returnOrderTail.getSourceInboundId())
: inboundSnapshots.findId(customerId, returnOrderTail.getInboundCode());
if (inboundOrderId == null) throw notFound("退货单关联的入仓快照不存在");
selectedOrders = List.of(returnOrderTail);
} else {
inboundOrderId = inboundSnapshots.findId(customerId, inboundCode);
if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode);
selectedOrders = selectedIds.isEmpty()
? orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getInboundCode, inboundCode))
: orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
}
// The merchant form may only submit the inventory inbound code. In that
// case derive all eligible tail orders for the inbound instead of
// rejecting a valid inventory transfer because source IDs were omitted.
selectedOrders = selectedIds.isEmpty()
? orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId)
.eq(LastlegOrder::getInboundCode, inboundCode))
: orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds));
if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && selectedOrders.size() != selectedIds.size())) {
if (selectedOrders.isEmpty() || (!selectedIds.isEmpty() && !isReturnOrderSource(inboundCode) && selectedOrders.size() != selectedIds.size())) {
throw notFound("One or more selected customer orders do not exist in the current tenant");
}
} else {
@@ -210,13 +278,13 @@ public class WarehouseTransferService {
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(destinationCode(request));
applyDestination(transfer, request);
transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount());
transfer.setDeliveryAddress(inboundAddress(request)); transfer.setPackageCount(request.packageCount());
transfer.setPickupContactName(trim(request.pickupContactName()));
transfer.setPickupContactPhone(trim(request.pickupContactPhone()));
transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt());
transfer.setDeliveryMethod(inboundMethod(request)); transfer.setEstimatedArrivalAt(arrivalTime(request));
transfer.setEstimatedShipAt(request.estimatedShipAt());
transfer.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim()) ? trim(request.trackingNo()) : null);
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(inboundMethod(request)) ? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark()));
transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(DRAFT);
transfer.setLabelDeadline(request.labelDeadline());
@@ -278,11 +346,12 @@ public class WarehouseTransferService {
if (!DRAFT.equals(transfer.getStatus())) {
throw conflict("Only a draft warehouse transfer order can be edited");
}
if (!request.isInboundModeValid()) throw badRequest("外来货物须填写入仓信息,库存转运须选择FBA仓或私人仓");
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) {
throw badRequest("Cargo item package total must equal transfer package count");
}
validateRelabelRequest(request);
validateRelabelRequest(customerId, request);
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
@@ -293,15 +362,15 @@ public class WarehouseTransferService {
transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim());
transfer.setFbaWarehouseCode(destinationCode(request));
applyDestination(transfer, request);
transfer.setDeliveryAddress(request.deliveryAddress().trim());
transfer.setDeliveryAddress(inboundAddress(request));
transfer.setPackageCount(request.packageCount());
transfer.setPickupContactName(trim(request.pickupContactName()));
transfer.setPickupContactPhone(trim(request.pickupContactPhone()));
transfer.setDeliveryMethod(request.deliveryMethod().trim());
transfer.setEstimatedArrivalAt(request.estimatedArrivalAt());
transfer.setDeliveryMethod(inboundMethod(request));
transfer.setEstimatedArrivalAt(arrivalTime(request));
transfer.setEstimatedShipAt(request.estimatedShipAt());
transfer.setFbaShipmentNo(trim(request.fbaShipmentNo()));
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim())
transfer.setTrackingNo("CUSTOMER_DELIVERY".equals(inboundMethod(request))
? trim(request.trackingNo()) : null);
transfer.setFbaBoxNos(trim(request.fbaBoxNos()));
transfer.setRemark(trim(request.remark()));
@@ -319,7 +388,7 @@ public class WarehouseTransferService {
order.setFbaWarehouseCode(destinationCode(request));
order.setPackageCount(request.packageCount());
order.setVolumeCbm(request.volumeCbm());
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setEstimatedWarehouseAt(arrivalTime(request));
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
order.setCustomerRemark(trim(request.remark()));
@@ -370,12 +439,26 @@ public class WarehouseTransferService {
}
@Transactional
/** 提交草稿前重新校验来源箱归属,防止草稿保存后库存已发生变化。 */
public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo);
if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted");
if (Boolean.TRUE.equals(transfer.getNeedRelabel())) {
Set<String> allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode())
? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())
.stream().map(WarehouseTransferResponses.SourceCarton::cartonMark).collect(Collectors.toSet())
: transferCartons.listOrder(customerId, transfer.getId()).stream()
.flatMap(carton -> java.util.stream.Stream.of(carton.getCartonMark(), carton.getCustomerCartonMark()))
.filter(StringUtils::hasText).collect(Collectors.toSet());
for (var binding : relabelBindings.listOrder(customerId, transfer.getId())) {
if (!allowedMarks.contains(binding.getCustomerCartonMark()))
throw badRequest("当前箱子不存在或不属于当前订单");
}
}
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
transfer.setStatus(PENDING_REVIEW);
@@ -859,7 +942,7 @@ public class WarehouseTransferService {
carton = new WarehouseTransferCarton(); carton.setId(IdWorker.getId());
carton.setTransferOrderId(transfer.getId()); carton.setTransferItemId(input.transferItemId()); carton.setCustomerId(customerId);
carton.setCartonIndex(++nextIndex); carton.setTotalCartons(transfer.getPackageCount());
carton.setCartonMark(transferNo + "#EX-" + nextIndex); carton.setCreatedAt(now);
carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId())); carton.setCreatedAt(now);
}
carton.setTransferPalletId(input.transferPalletId());
carton.setLengthCm(input.length()); carton.setWidthCm(input.width()); carton.setHeightCm(input.height());
@@ -1712,7 +1795,8 @@ public class WarehouseTransferService {
}
}
private void validateRelabelRequest(WarehouseTransferRequests.Create request) {
/** 校验换标资料及箱唛归属,退货来源同样按当前可用箱校验。 */
private void validateRelabelRequest(String customerId, WarehouseTransferRequests.Create request) {
if (!request.needRelabel()) {
if (request.relabelItems() != null && !request.relabelItems().isEmpty()) {
throw badRequest("Relabel items require needRelabel=true");
@@ -1720,6 +1804,14 @@ public class WarehouseTransferService {
return;
}
if (request.relabelItems() == null || request.relabelItems().isEmpty()) return;
Set<String> orderCartonMarks = StringUtils.hasText(request.inboundCode())
? sourceCartons(customerId, request.operatingWarehouseId().trim(), request.inboundCode().trim())
.stream().map(WarehouseTransferResponses.SourceCarton::cartonMark)
.filter(StringUtils::hasText).map(String::trim).collect(Collectors.toSet())
: request.items().stream().flatMap(item -> splitMarks(item.shippingMark()).stream()).collect(Collectors.toSet());
if (!StringUtils.hasText(request.inboundCode()) && orderCartonMarks.isEmpty()) {
orderCartonMarks.addAll(splitMarks(request.fbaBoxNos()));
}
if (request.labelDeadline() == null || !StringUtils.hasText(request.relabelContactName())
|| !StringUtils.hasText(request.relabelContactPhone())) {
throw badRequest("Relabel deadline and contact are required");
@@ -1754,6 +1846,7 @@ public class WarehouseTransferService {
throw badRequest("SKU relabel requires a new SKU");
}
for (String mark : row.customerCartonMarks()) {
if (!orderCartonMarks.contains(mark.trim())) throw badRequest("当前箱子不存在或不属于当前订单");
if (!marks.add(mark.trim().toUpperCase())) throw badRequest("Customer carton marks must be unique");
}
}
@@ -1969,7 +2062,7 @@ public class WarehouseTransferService {
private LastlegOrder createTailOrder(String customerId, String customerName, Long inboundOrderId, String inboundCode, String transferNo,
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next("TRANSFER", request.operatingWarehouseId(), order.getId())); order.setCustomerId(customerId);
order.setId(IdWorker.getId()); order.setOrderNo(transferNo); order.setCustomerId(customerId);
order.setCustomerName(customerName);
order.setOrderType(ORDER_TYPE);
order.setInboundCode(inboundCode); order.setSourceRegion("CUSTOMER");
@@ -1979,7 +2072,7 @@ public class WarehouseTransferService {
order.setVolumeCbm(request.volumeCbm());
order.setContainerNo(transferNo);
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setEstimatedWarehouseAt(arrivalTime(request));
order.setCurrentStatus(DRAFT);
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
@@ -2007,7 +2100,7 @@ public class WarehouseTransferService {
String customerCartonMark, LocalDateTime now) {
WarehouseTransferCarton carton = new WarehouseTransferCarton();
carton.setId(IdWorker.getId()); carton.setTransferOrderId(transferId); carton.setTransferItemId(transferItemId);
carton.setCustomerId(customerId); carton.setCartonMark(transferNo + "#" + totalCartons + "-" + cartonIndex);
carton.setCustomerId(customerId); carton.setCartonMark(LastlegOrderNoGenerator.compactCartonMark(carton.getId()));
carton.setCustomerCartonMark(trim(customerCartonMark));
carton.setCartonIndex(cartonIndex); carton.setTotalCartons(totalCartons); carton.setStatus("EXPECTED");
carton.setCreatedAt(now); carton.setUpdatedAt(now); transferCartons.insert(carton);
@@ -2020,7 +2113,7 @@ public class WarehouseTransferService {
detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index);
detail.setSkuCode(trim(source.skuCode())); detail.setProductNameZh(trim(source.productNameZh()));
detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(destinationCode(request));
detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(request.deliveryMethod().trim())
detail.setFbaTrackingNo("CUSTOMER_DELIVERY".equals(inboundMethod(request))
? trim(request.trackingNo()) : null);
detail.setShipmentNo(trim(request.fbaShipmentNo()));
detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail);
@@ -2058,6 +2151,10 @@ public class WarehouseTransferService {
return auth == null ? null : auth.getName();
}
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
/** 根据独立前缀识别退货来源,避免与普通入仓码混淆。 */
private static boolean isReturnOrderSource(String value) { return StringUtils.hasText(value) && value.trim().startsWith(RETURN_ORDER_PREFIX); }
/** 从转运来源编码提取真实退货单号。 */
private static String returnNoFromSource(String value) { return value.trim().substring(RETURN_ORDER_PREFIX.length()).trim(); }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first.trim() : trim(second); }
private static long packages(Long value) { return value == null ? 0L : value; }
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }