fix: carry partial dropship measurements to outbound
This commit is contained in:
+26
@@ -203,6 +203,32 @@ class LoadingCompletedOutboxServiceTest {
|
||||
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
|
||||
}
|
||||
|
||||
@Test void publishesOnlyTheActuallyLoadedFragmentMeasurements() throws Exception {
|
||||
Row loadedFragment = row(11, 100, 101);
|
||||
loadedFragment.setPackageCount(4);
|
||||
loadedFragment.setGrossWeightKg(new BigDecimal("40"));
|
||||
loadedFragment.setNetWeightKg(new BigDecimal("36"));
|
||||
loadedFragment.setDimensionalWeightKg(new BigDecimal("80"));
|
||||
loadedFragment.setVolumeCbm(new BigDecimal("4"));
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(loadedFragment));
|
||||
when(manifests.labelPool(11L, 100L)).thenReturn(List.of(
|
||||
label(101, "BOX-001"), label(101, "BOX-002"),
|
||||
label(101, "BOX-003"), label(101, "BOX-004")));
|
||||
|
||||
service.publish(90L);
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(outbox).insert(captor.capture());
|
||||
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(event.payload().at("/orders/0/packageCount").asInt()).isEqualTo(4);
|
||||
assertThat(event.payload().at("/orders/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40");
|
||||
assertThat(event.payload().at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("80");
|
||||
assertThat(event.payload().at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("4");
|
||||
assertThat(event.payload().at("/orders/0/items/0/packageCount").asInt()).isEqualTo(4);
|
||||
assertThat(event.payload().at("/orders/0/items/0/grossWeightKg").decimalValue()).isEqualByComparingTo("40");
|
||||
assertThat(event.payload().at("/orders/0/items/0/volumeCbm").decimalValue()).isEqualByComparingTo("4");
|
||||
}
|
||||
|
||||
@Test void immutableCustomerNameParticipatesInPayloadHash() {
|
||||
Row value = row(11, 100, 101);
|
||||
when(snapshots.rows(90L)).thenReturn(List.of(value));
|
||||
|
||||
+2
-1
@@ -59,7 +59,8 @@ public final class DeliveryResponses {
|
||||
public record OutboundDetail(OutboundRow outbound, List<DeliveryRow> deliveries, List<OutboundPalletRow> pallets) {}
|
||||
public record OneClickCandidate(Long inventoryPositionId, String orderNo, String customerId, String customerName, String overseasWarehouseId,
|
||||
String overseasWarehouseName, String palletNo, String inboundCode,
|
||||
String skuCode, String productName, long availablePackages) {}
|
||||
String skuCode, String productName, long availablePackages,
|
||||
BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm) {}
|
||||
public record ReturnOutboundCandidate(String returnNo, String overseasWarehouseId,
|
||||
String cartonMark, String fbaCartonMark,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
|
||||
+39
-7
@@ -76,6 +76,7 @@ import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -322,8 +323,10 @@ public class DeliveryTransferService {
|
||||
if (concurrent == null) throw duplicate;
|
||||
return replayOneClick(concurrent, requestHash);
|
||||
}
|
||||
selectedWarehouseId = validateOneClickItems(customerId, request.items());
|
||||
OneClickValidation validation = validateOneClickItems(customerId, request.items());
|
||||
selectedWarehouseId = validation.warehouseId();
|
||||
Set<Long> physicalPalletIds = new HashSet<>();
|
||||
List<OutboundPalletItem> createdMetricItems = new ArrayList<>();
|
||||
for (DeliveryRequests.OneClickItem input : request.items()) {
|
||||
InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId());
|
||||
if (position == null || !customerId.equals(position.getCustomerId())) throw notFound("Inventory position not found");
|
||||
@@ -334,10 +337,21 @@ public class DeliveryTransferService {
|
||||
OutboundPalletItem item = new OutboundPalletItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
|
||||
item.setInboundCode(balance.getInboundCode());
|
||||
physicalPalletIds.add(position.getPalletId());
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
|
||||
DropshipOutboundCandidate candidate = validation.candidatesByRequest().get(
|
||||
oneClickCandidateKey(input.inventoryPositionId(), input.sku()));
|
||||
if (candidate == null) throw conflict("一件代发出库重量体积快照缺失");
|
||||
item.setWarehousePalletId(position.getPalletId()); item.setPalletNo(position.getPalletNo()); item.setPackageCount(input.packages().intValue());
|
||||
item.setGrossWeightKg(allocateMetric(candidate.getSourceGrossWeightKg(), candidate.getSourcePackageCount(), input.packages(), 3));
|
||||
item.setVolumeWeightKg(allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), input.packages(), 3));
|
||||
item.setVolumeCbm(allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), input.packages(), 4));
|
||||
item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setSkuCode(input.sku().trim()); item.setCartonMarks(String.join(",", input.cartonMarks() == null ? List.of() : input.cartonMarks())); item.setCreatedAt(now);
|
||||
outboundPallets.insert(item);
|
||||
createdMetricItems.add(item);
|
||||
}
|
||||
outbound.setPalletCount(physicalPalletIds.size());
|
||||
outbound.setGrossWeightKg(sum(createdMetricItems, OutboundPalletItem::getGrossWeightKg));
|
||||
outbound.setVolumeWeightKg(sum(createdMetricItems, OutboundPalletItem::getVolumeWeightKg));
|
||||
outbound.setVolumeCbm(sum(createdMetricItems, OutboundPalletItem::getVolumeCbm));
|
||||
outbound.setOverseasWarehouseId(selectedWarehouseId); outboundOrders.updateById(outbound);
|
||||
OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setOutboundOrderId(outbound.getId());
|
||||
task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
|
||||
@@ -558,10 +572,11 @@ public class DeliveryTransferService {
|
||||
return outboundDetail(outbound.getOutboundNo());
|
||||
}
|
||||
|
||||
private String validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
|
||||
private OneClickValidation validateOneClickItems(String customerId, List<DeliveryRequests.OneClickItem> items) {
|
||||
String warehouseId = null;
|
||||
Map<String, Long> requestedPackages = new LinkedHashMap<>();
|
||||
Map<String, DropshipOutboundCandidate> candidates = new HashMap<>();
|
||||
Map<String, DropshipOutboundCandidate> candidatesByRequest = new HashMap<>();
|
||||
Set<String> requestedMarks = new HashSet<>();
|
||||
for (DeliveryRequests.OneClickItem input : items) {
|
||||
String sku = input.sku().trim();
|
||||
@@ -577,6 +592,7 @@ public class DeliveryTransferService {
|
||||
String key = candidate.getProfileId() + "\u0000" + sku;
|
||||
requestedPackages.merge(key, input.packages(), Long::sum);
|
||||
candidates.put(key, candidate);
|
||||
candidatesByRequest.put(oneClickCandidateKey(input.inventoryPositionId(), sku), candidate);
|
||||
if (input.cartonMarks() != null && !input.cartonMarks().isEmpty()) {
|
||||
if (input.cartonMarks().size() != input.packages() || new HashSet<>(input.cartonMarks()).size() != input.cartonMarks().size()) {
|
||||
throw conflict("Specified carton marks must be unique and equal the package count");
|
||||
@@ -602,7 +618,7 @@ public class DeliveryTransferService {
|
||||
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
|
||||
}
|
||||
}
|
||||
return warehouseId;
|
||||
return new OneClickValidation(warehouseId, Map.copyOf(candidatesByRequest));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -1185,10 +1201,14 @@ public class DeliveryTransferService {
|
||||
item.getVolumeCbm(), value(item.getPickedPackageCount()), item.getStatus());
|
||||
}
|
||||
private DeliveryResponses.OneClickCandidate oneClickCandidate(DropshipOutboundCandidate candidate) {
|
||||
long available = value(candidate.getAvailablePackages());
|
||||
return new DeliveryResponses.OneClickCandidate(candidate.getInventoryPositionId(), candidate.getOrderNo(), candidate.getCustomerId(), candidate.getCustomerName(),
|
||||
candidate.getOverseasWarehouseId(), candidate.getOverseasWarehouseName(), candidate.getPalletNo(),
|
||||
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
|
||||
value(candidate.getAvailablePackages()));
|
||||
available,
|
||||
allocateMetric(candidate.getSourceGrossWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
||||
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
|
||||
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
@@ -1234,6 +1254,8 @@ public class DeliveryTransferService {
|
||||
throw new IllegalStateException("无法生成整托拣货幂等号", error);
|
||||
}
|
||||
}
|
||||
private record OneClickValidation(String warehouseId,
|
||||
Map<String, DropshipOutboundCandidate> candidatesByRequest) {}
|
||||
private record PalletPickPlan(Long itemId, Long positionId, long packages,
|
||||
List<String> cartonMarks, int sequence) {}
|
||||
private record SkuShipment(long packages, long quantity) {
|
||||
@@ -1242,10 +1264,20 @@ public class DeliveryTransferService {
|
||||
Math.addExact(quantity, other.quantity));
|
||||
}
|
||||
}
|
||||
private static BigDecimal sum(List<DispatchItem> items,
|
||||
java.util.function.Function<DispatchItem, BigDecimal> getter) {
|
||||
private static <T> BigDecimal sum(List<T> items,
|
||||
java.util.function.Function<T, BigDecimal> getter) {
|
||||
return items.stream().map(getter).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
private static BigDecimal allocateMetric(BigDecimal sourceValue, Long sourcePackages,
|
||||
long selectedPackages, int scale) {
|
||||
if (sourceValue == null || sourceValue.signum() == 0 || sourcePackages == null
|
||||
|| sourcePackages <= 0 || selectedPackages <= 0) return BigDecimal.ZERO.setScale(scale);
|
||||
return sourceValue.multiply(BigDecimal.valueOf(selectedPackages))
|
||||
.divide(BigDecimal.valueOf(sourcePackages), scale, RoundingMode.HALF_UP);
|
||||
}
|
||||
private static String oneClickCandidateKey(Long inventoryPositionId, String sku) {
|
||||
return inventoryPositionId + "\u0000" + trim(sku);
|
||||
}
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
|
||||
+7
-1
@@ -17,7 +17,13 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
|
||||
"WHERE s.customer_id=p.customer_id AND s.profile_id=p.id) product_name," +
|
||||
"LEAST(pos.available_packages,(SELECT COUNT(*) FROM ll_dropship_carton c " +
|
||||
"WHERE c.customer_id=p.customer_id AND c.profile_id=p.id " +
|
||||
"AND c.status='AVAILABLE')) available_packages FROM ll_dropship_order_profile p " +
|
||||
"AND c.status='AVAILABLE')) available_packages,COALESCE(o.package_count,0) source_package_count," +
|
||||
"COALESCE((SELECT SUM(d.gross_weight_kg) FROM ll_order_detail d " +
|
||||
"WHERE d.customer_id=o.customer_id AND d.order_id=o.id)," +
|
||||
"(SELECT inbound.gross_weight_kg FROM ll_inbound_order inbound " +
|
||||
"WHERE inbound.customer_id=o.customer_id AND inbound.id=o.id LIMIT 1),0) source_gross_weight_kg," +
|
||||
"COALESCE(o.volume_weight_kg,0) source_volume_weight_kg,COALESCE(o.volume_cbm,0) source_volume_cbm " +
|
||||
"FROM ll_dropship_order_profile p " +
|
||||
"JOIN ll_order o ON o.id=p.order_id AND o.customer_id=p.customer_id " +
|
||||
"AND o.order_type='ONE_CLICK_DROPSHIP' AND COALESCE(o.detained,0)=0 " +
|
||||
"JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=p.customer_id " +
|
||||
|
||||
+7
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.dropship.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** Server-authoritative one-click dropship inventory dimension eligible for outbound creation. */
|
||||
@Data
|
||||
public class DropshipOutboundCandidate {
|
||||
@@ -18,4 +20,9 @@ public class DropshipOutboundCandidate {
|
||||
private String skuCode;
|
||||
private String productName;
|
||||
private Long availablePackages;
|
||||
/** Loaded-order denominator and immutable metrics used for proportional outbound snapshots. */
|
||||
private Long sourcePackageCount;
|
||||
private BigDecimal sourceGrossWeightKg;
|
||||
private BigDecimal sourceVolumeWeightKg;
|
||||
private BigDecimal sourceVolumeCbm;
|
||||
}
|
||||
|
||||
@@ -2474,7 +2474,7 @@ components:
|
||||
items: { type: string, minLength: 1, maxLength: 128 }
|
||||
OneClickOutboundCandidate:
|
||||
type: object
|
||||
required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages]
|
||||
required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages, grossWeightKg, volumeWeightKg, volumeCbm]
|
||||
properties:
|
||||
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$', description: 创建命令提交键,不作为用户展示编号 }
|
||||
overseasWarehouseId: { type: string }
|
||||
@@ -2484,6 +2484,9 @@ components:
|
||||
skuCode: { type: string }
|
||||
productName: { type: string, nullable: true }
|
||||
availablePackages: { type: integer, format: int64, minimum: 1, description: 库存位置、SKU余额和AVAILABLE箱唛三者的可用最小值 }
|
||||
grossWeightKg: { type: number, minimum: 0, description: 当前可出箱数按装柜实装箱数比例分摊的毛重 }
|
||||
volumeWeightKg: { type: number, minimum: 0, description: 当前可出箱数按装柜实装箱数比例分摊的材积重 }
|
||||
volumeCbm: { type: number, minimum: 0, description: 当前可出箱数按装柜实装箱数比例分摊的体积 }
|
||||
OutboundCancel:
|
||||
type: object
|
||||
required: [reason]
|
||||
|
||||
+42
-1
@@ -491,6 +491,40 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals(2, order.getValue().getPackageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickOutboundAllocatesLoadedMetricsBySelectedPackages() {
|
||||
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "DP-SPLIT", "SKU-1", 4L);
|
||||
candidate.setSourcePackageCount(4L);
|
||||
candidate.setSourceGrossWeightKg(new BigDecimal("40.000"));
|
||||
candidate.setSourceVolumeWeightKg(new BigDecimal("80.000"));
|
||||
candidate.setSourceVolumeCbm(new BigDecimal("4.0000"));
|
||||
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "SKU-1")).thenReturn(candidate);
|
||||
when(dropshipCartons.lockAvailablePhysical("TENANT-A", 1001L, 2L))
|
||||
.thenReturn(List.of(new DropshipCarton(), new DropshipCarton()));
|
||||
InventoryPosition position = new InventoryPosition();
|
||||
position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L);
|
||||
position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("DP-SPLIT");
|
||||
InventoryBalance balance = new InventoryBalance();
|
||||
balance.setId(801L); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-SPLIT");
|
||||
when(inventoryPositions.selectById(901L)).thenReturn(position);
|
||||
when(inventoryBalances.selectById(801L)).thenReturn(balance);
|
||||
|
||||
service.createOneClick("TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
|
||||
new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1")), null, "split-metrics"));
|
||||
|
||||
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundPallets).insert(item.capture());
|
||||
assertEquals(new BigDecimal("20.000"), item.getValue().getGrossWeightKg());
|
||||
assertEquals(new BigDecimal("40.000"), item.getValue().getVolumeWeightKg());
|
||||
assertEquals(new BigDecimal("2.0000"), item.getValue().getVolumeCbm());
|
||||
ArgumentCaptor<OutboundOrder> order = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
verify(outboundOrders).updateById(order.capture());
|
||||
assertEquals(2, order.getValue().getPackageCount());
|
||||
assertEquals(new BigDecimal("20.000"), order.getValue().getGrossWeightKg());
|
||||
assertEquals(new BigDecimal("40.000"), order.getValue().getVolumeWeightKg());
|
||||
assertEquals(new BigDecimal("2.0000"), order.getValue().getVolumeCbm());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() {
|
||||
var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class);
|
||||
@@ -563,6 +597,9 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("DS-A002", result.get(0).palletNo());
|
||||
assertEquals("SKU-1", result.get(0).skuCode());
|
||||
assertEquals(3L, result.get(0).availablePackages());
|
||||
assertEquals(new BigDecimal("30.000"), result.get(0).grossWeightKg());
|
||||
assertEquals(new BigDecimal("60.000"), result.get(0).volumeWeightKg());
|
||||
assertEquals(new BigDecimal("3.0000"), result.get(0).volumeCbm());
|
||||
verify(dropshipProfiles).listOutboundCandidates("TENANT-A", "WH-1");
|
||||
verify(inventoryPositions, never()).selectById(any());
|
||||
}
|
||||
@@ -981,7 +1018,11 @@ class DeliveryTransferServiceTest {
|
||||
DropshipOutboundCandidate value = new DropshipOutboundCandidate();
|
||||
value.setInventoryPositionId(positionId); value.setProfileId(profileId); value.setWarehousePalletId(501L);
|
||||
value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku);
|
||||
value.setAvailablePackages(packages); return value;
|
||||
value.setAvailablePackages(packages); value.setSourcePackageCount(packages);
|
||||
value.setSourceGrossWeightKg(BigDecimal.valueOf(packages * 10L));
|
||||
value.setSourceVolumeWeightKg(BigDecimal.valueOf(packages * 20L));
|
||||
value.setSourceVolumeCbm(BigDecimal.valueOf(packages));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DropshipCartonSku cartonSku(String sku, long quantity) {
|
||||
|
||||
Reference in New Issue
Block a user