This commit is contained in:
2026-10-14 13:42:58 +08:00
parent bc9c78122b
commit d017f5d881
9 changed files with 239 additions and 6 deletions
@@ -227,7 +227,7 @@ public interface DispatchCandidateMapper {
@Select("<script>" +
"SELECT NULL AS planId,NULL AS planPalletId,pos.pallet_id AS warehousePalletId,a.lastleg_order_id AS orderId," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,NULL AS grossWeightKg,NULL AS containerNo," +
"a.customer_id AS customerId,NULL AS customerName,NULL AS planNo,'RELABEL_APPLICATION' AS orderType,NULL AS volumeWeightKg,MAX(wtp.gross_weight_kg) AS grossWeightKg,NULL AS containerNo," +
"NULL AS appointmentAt,NULL AS expectedLoadingAt,a.overseas_warehouse_id AS overseasWarehouseId,MIN(ri.source_warehouse_code) AS overseasWarehouseCode,MIN(ri.source_warehouse_name) AS overseasWarehouseName," +
"MIN(COALESCE(NULLIF(ri.source_inbound_order_no,''),NULLIF(a.customer_order_no,''),a.relabel_no)) AS orderNo,a.relabel_no AS orderRefs,MIN(ri.source_inbound_code) AS inboundCode,pos.pallet_no AS palletNo,NULL AS destinationWarehouseId," +
"GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode," +
@@ -79,6 +79,7 @@ public class DispatchService {
@Resource private RelabelApplicationMapper relabelApplications;
@Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard;
@Resource private ReturnRelabelDispatchStock returnRelabelStock;
@Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements;
public String resolveCustomerScopeForPlans(List<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
@@ -124,7 +125,7 @@ public class DispatchService {
trimToNull(sourceStage), trimToNull(draftToken));
if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) {
List<DispatchPalletCandidate> relabel = java.util.stream.Stream.concat(
candidates.relabelAvailable(null, trimToNull(orderNo), null).stream(),
relabelMeasurements.enrich(candidates.relabelAvailable(null, trimToNull(orderNo), null)).stream(),
returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList();
if (relabel != null && !relabel.isEmpty()) {
result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item ->
@@ -575,10 +576,11 @@ public class DispatchService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application);
if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态");
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
candidates.relabelAvailable(customerId, null, request.relabelNo()).stream(),
relabelMeasurements.enrich(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource)
@@ -20,6 +20,20 @@ import org.springframework.web.bind.annotation.RestController;
@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')")
public class PdaPalletOperationController {
@Resource private PdaPalletOperationService service;
@Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService measurements;
@PostMapping("/relabel-pallet-measurements")
public ApiResponse<java.util.List<com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement>> measurements(
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody PalletOperationRequests.RelabelSources request) {
return ApiResponse.success(measurements.list(PdaWarehouseIdentity.require(user), request.relabelNo()));
}
@PostMapping("/relabel-pallet-measure")
public ApiResponse<java.util.List<com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement>> measure(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measure request) {
return ApiResponse.success(measurements.save(PdaWarehouseIdentity.require(user), request));
}
@PostMapping("/pallets/resolve")
public ApiResponse<PalletOperationResponses.PalletContent> resolve(
@@ -233,7 +233,6 @@ public class PdaPalletOperationService {
List.of(request.target())).get(code(request.target().palletCode()));
PalletPhysicalGateway.PalletReference source = request.source() == null ? null
: resolvePallets(identity.overseasWarehouseId(), List.of(request.source())).get(code(request.source().palletCode()));
if (source != null && source.palletId() == target.palletId()) throw conflict("原托盘和目标托盘不能相同");
Map<String, PalletPhysicalGateway.PalletReference> pallets = new LinkedHashMap<>();
if (source != null) pallets.put(source.palletCode(), source);
pallets.put(target.palletCode(), target);
@@ -283,8 +282,37 @@ public class PdaPalletOperationService {
}
}
if (returnRelabel) {
if (source != null && source.palletId() == target.palletId()) throw conflict("退货换标请使用独立目标托盘");
return bindReturnRelabelPallet(identity, application, selected, source, target, request, unboundReturnRelabel);
}
if (source.palletId() == target.palletId()) {
return transaction.execute(status -> {
var lockedApp = relabelApplications.lockAnyByRelabelNo(application.getRelabelNo());
requireRelabelWork(identity, lockedApp);
long total = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), source.palletId());
long selectedPackages = selected.stream().mapToLong(i -> value(i.getNotifiedPackageCount())).sum();
if (total <= 0 || total != selectedPackages) throw conflict("部分贴标必须拆到新托盘;只有整托全部贴标可保留原托盘");
Map<Long, List<RelabelItem>> byPosition = selected.stream().collect(Collectors.groupingBy(RelabelItem::getSourceInventoryPositionId));
Map<Long, InventoryPosition> retained = new LinkedHashMap<>();
for (var entry : byPosition.entrySet()) {
var row = mapper.lockPosition(application.getOverseasWarehouseId(), entry.getKey());
long count = entry.getValue().stream().mapToLong(i -> value(i.getNotifiedPackageCount())).sum();
if (row == null || !application.getCustomerId().equals(row.getCustomerId())
|| !Objects.equals(row.getPalletId(), source.palletId()) || value(row.getHoldPackages()) < count)
throw conflict("原托盘换标库存已变化,请刷新后核对");
requirePalletReservation(application.getCustomerId(), entry.getKey(), inventoryCommands.relabelTransfer(application.getCustomerId(), application.getId()));
retained.put(entry.getKey(), positions.selectById(entry.getKey()));
}
requireRelabelDestination(identity.overseasWarehouseId(), selected, target);
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);
}
return new PalletOperationResponses.Operation(IdWorker.getId(), "RELABEL_PALLET_RETAIN", request.idempotencyKey(),
"COMPLETED", false, LocalDateTime.now(), List.of(content(identity.overseasWarehouseId(), source)));
});
}
for (RelabelItem item : selected) {
if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null
|| item.getNotifiedPackageCount() <= 0) throw conflict("换标箱唛缺少库存位置或箱数快照");
@@ -808,7 +836,9 @@ public class PdaPalletOperationService {
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())));
"targetPallets", targets.values().stream().map(InventoryPosition::getPalletNo).distinct().toList(),
"sourcePallets", mutation.itemsByPosition().keySet().stream().map(positions::selectById)
.filter(Objects::nonNull).map(InventoryPosition::getPalletNo).distinct().toList())));
relabelWorkHistory.insert(record);
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.relabel.mapper;
import org.apache.ibatis.annotations.*;
import java.math.BigDecimal;
import java.util.List;
@Mapper
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) {}
@Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo}")
Long transfer(String customerId, String relabelNo);
@Select("SELECT id,pallet_id palletId,pallet_no palletNo," +
"COALESCE(available_packages,0)+COALESCE(hold_packages,0)+COALESCE(allocated_packages,0)+COALESCE(picked_packages,0)+COALESCE(receiving_packages,0) packages,version " +
"FROM ll_inventory_position WHERE customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} ORDER BY id")
List<Stock> stock(String customerId, String warehouseId);
@Select("SELECT warehouse_pallet_id palletId,pallet_no palletNo,carton_count packages,length_cm lengthCm,width_cm widthCm,height_cm heightCm,gross_weight_kg grossWeightKg,volume_cbm volumeCbm " +
"FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customerId} AND transfer_order_id=#{transferId} AND status='COMPLETED'")
List<Original> originals(String customerId, Long transferId);
@Select("SELECT COUNT(*) FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
"AND business_type IN ('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND')")
int movements(String customerId, Long palletId);
}
@@ -0,0 +1,147 @@
package com.baiyue.lastleg.relabel.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.*;
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory;
import com.baiyue.lastleg.relabel.pda.mapper.*;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.math.*;
import java.time.LocalDateTime;
import java.util.*;
/** Measurements are audited relabel facts, valid only for the measured inventory composition/version. */
@Service
public class RelabelPalletMeasurementService {
@Resource private RelabelPalletMeasurementMapper mapper;
@Resource private RelabelApplicationMapper applications;
@Resource private RelabelItemMapper items;
@Resource private PdaRelabelHistoryMapper histories;
@Resource private PdaRelabelTaskMapper tasks;
@Resource private ObjectMapper json;
public record Measure(@NotBlank String relabelNo, @NotBlank String palletCode,
@NotBlank String idempotencyKey, @NotBlank String stockVersion,
@NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal lengthCm,
@NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal widthCm,
@NotNull @DecimalMin("0.01") @Digits(integer=6,fraction=2) BigDecimal heightCm,
@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) {}
public List<Measurement> list(PdaWarehouseIdentity identity, String relabelNo) {
return snapshots(scoped(identity, relabelNo, false));
}
private RelabelApplication scoped(PdaWarehouseIdentity identity, String no, boolean lock) {
var app = lock ? applications.lockAnyByRelabelNo(no) : applications.findAnyByRelabelNo(no);
if (app == null || !Objects.equals(app.getOverseasWarehouseId(), String.valueOf(identity.overseasWarehouseId())))
throw conflict("换标单不属于当前海外仓");
return app;
}
@Transactional
public List<Measurement> save(PdaWarehouseIdentity identity, Measure request) {
var app = scoped(identity, request.relabelNo(), true);
if (!Set.of("PROCESSING", "READY_FOR_OUTBOUND").contains(app.getStatus())
|| app.getOutboundOrderNo() != null && !app.getOutboundOrderNo().isBlank()
|| app.getFulfillmentStatus() != null && !Set.of("WAIT_RELABEL", "WAIT_PRE_DISPATCH").contains(app.getFulfillmentStatus()))
throw conflict("已排车或出库的换标单不能修改测量");
var task = tasks.find(app.getCustomerId(), app.getId());
if (task == null || "PROCESSING".equals(app.getStatus()) && !identity.operatorKey().equals(task.getOperatorKey()))
throw conflict("请先认领当前换标任务");
var records = histories(app);
String payload = write(request);
for (var history : records) if ("PALLET_MEASURE".equals(history.getAction())) {
var data = tree(history);
if (request.idempotencyKey().equals(data.path("request").path("idempotencyKey").asText())) {
if (!payload.equals(write(json.convertValue(data.path("request"), Measure.class))))
throw conflict("测量幂等键不能用于不同数据");
return snapshots(app);
}
}
var current = snapshots(app).stream().filter(p -> p.palletCode().equals(request.palletCode())).findFirst()
.orElseThrow(() -> conflict("托盘不属于本换标单或已经清空"));
if (!current.stockVersion().equals(request.stockVersion())) 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);
var history = new PdaRelabelHistory(); history.setId(IdWorker.getId()); history.setTaskId(task.getId());
history.setApplicationId(app.getId()); history.setCustomerId(app.getCustomerId()); history.setAction("PALLET_MEASURE");
history.setOperatorKey(identity.operatorKey()); history.setOperatorName(identity.operatorName());
history.setFromStatus(task.getStatus()); history.setToStatus(task.getStatus()); history.setCreatedAt(LocalDateTime.now());
history.setDetailJson(write(Map.of("request", request, "measurement", measured))); histories.insert(history);
return snapshots(app);
}
public List<Measurement> snapshots(RelabelApplication app) {
Long transferId = mapper.transfer(app.getCustomerId(), app.getRelabelNo());
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 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 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()));
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(","));
Measurement value = null;
for (var h : records) if ("PALLET_MEASURE".equals(h.getAction())) {
var measured = json.convertValue(tree(h).path("measurement"), Measurement.class);
if (pallet.equals(measured.palletId()) && version.equals(measured.stockVersion())) value = measured;
}
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);
}
result.add(value == null ? new Measurement(pallet, rows.get(0).palletNo(), count, version, null,null,null,null,null,false) : value);
}
return result;
}
public void requireMeasured(RelabelApplication app) {
for (var p : snapshots(app)) if (p.grossWeightKg() == null || p.volumeCbm() == null
|| p.grossWeightKg().signum() <= 0 || p.volumeCbm().signum() <= 0)
throw conflict("托盘 " + p.palletCode() + " 拆合托后尚未确认实测尺寸和重量,请在 PDA 补测(含原托盘剩余货物)");
}
public List<DispatchPalletCandidate> enrich(List<DispatchPalletCandidate> candidates) {
var cache = new HashMap<String,List<Measurement>>();
for (var c : candidates) {
var values = cache.computeIfAbsent(c.customerId()+":"+c.orderRefs(), ignored -> {
var app = applications.findAnyByRelabelNo(c.orderRefs());
return app != null && Objects.equals(app.getCustomerId(),c.customerId()) ? snapshots(app) : List.of();
});
if (!values.isEmpty()) {
var p = values.stream().filter(v -> Objects.equals(v.palletId(), c.warehousePalletId())).findFirst().orElse(null);
c.grossWeightKg(p == null ? null : p.grossWeightKg()); c.volumeCbm(p == null ? null : p.volumeCbm());
}
}
return candidates;
}
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"));
}
private com.fasterxml.jackson.databind.JsonNode tree(PdaRelabelHistory h) {
try { return json.readTree(h.getDetailJson()); } catch (Exception e) { throw conflict("托盘测量记录无法读取"); }
}
private String write(Object value) { try { return json.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
}
@@ -5,6 +5,8 @@ import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.util.ArrayList;
import java.util.List;
@@ -187,6 +189,17 @@ class DispatchCandidateMapperContractTest {
}
}
@Test
void relabelCandidatesReadWeightFromWarehouseTransferPalletSnapshot() throws Exception {
String source = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java"));
int start = source.indexOf("List<DispatchPalletCandidate> relabelAvailable");
int end = source.indexOf("List<DispatchPalletCandidate> nativeReturnRelabelAvailable");
String sql = source.substring(source.lastIndexOf("@Select", start), end);
assertTrue(sql.contains("MAX(wtp.gross_weight_kg) AS grossWeightKg"));
assertTrue(sql.contains("MAX(wtp.volume_cbm) AS volumeCbm"));
assertTrue(sql.contains("wtp.warehouse_pallet_id=pos.pallet_id"));
}
@Test
void candidateGrossWeightUsesPhysicalSourcesAndNeverBillingVolumeWeight() throws Exception {
for (Method method : List.of(
@@ -65,11 +65,14 @@ class DispatchServiceTest {
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow;
@Mock ReturnRelabelDispatchStock returnRelabelStock;
@Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements;
private DispatchService service;
@BeforeEach
void setUp() {
service = new DispatchService();
ReflectionTestUtils.setField(service, "relabelMeasurements", relabelMeasurements);
lenient().when(relabelMeasurements.enrich(any())).thenAnswer(invocation -> invocation.getArgument(0));
ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow);
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
ReflectionTestUtils.setField(service, "returnRelabelStock", returnRelabelStock);
@@ -28,7 +28,7 @@ class PdaPalletOperationControllerContractTest {
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
assertEquals(Set.of("/pallets/resolve", "/relabel-pallet-label", "/assemble", "/disassemble",
"/relabel-disassemble", "/relabel-sources", "/swap", "/unbind"), paths);
"/relabel-disassemble", "/relabel-sources", "/relabel-pallet-measure", "/relabel-pallet-measurements", "/swap", "/unbind"), paths);
}
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {