fix: 按物理箱贯通一件代发履约箱数

This commit is contained in:
2026-09-16 20:49:35 +08:00
parent 8797c0bef9
commit 545ad2083d
34 changed files with 779 additions and 132 deletions
File diff suppressed because one or more lines are too long
@@ -21,18 +21,44 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
+ "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId);
@Select("SELECT COALESCE(SUM(package_count),0) FROM fl_loading_plan_item "
+ "WHERE loading_plan_id=#{loadingPlanId} AND account_id=#{tenantId} "
+ "AND inbound_id=#{inboundId} AND deleted=0")
@Select("""
SELECT COALESCE(SUM(CASE WHEN item.pallet_id IS NULL THEN item.package_count ELSE 0 END),0)
+ COALESCE((SELECT COUNT(DISTINCT carton.id)
FROM fl_dropship_pallet_carton carton
WHERE carton.tenant_id=#{tenantId} AND carton.inbound_id=#{inboundId}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.loading_plan_id=#{loadingPlanId}
AND pallet_item.account_id=#{tenantId}
AND pallet_item.inbound_id=#{inboundId}
AND pallet_item.pallet_id=carton.pallet_id
AND pallet_item.deleted=0)),0)
FROM fl_loading_plan_item item
WHERE item.loading_plan_id=#{loadingPlanId} AND item.account_id=#{tenantId}
AND item.inbound_id=#{inboundId} AND item.deleted=0
""")
int allocatedPackages(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("loadingPlanId") long loadingPlanId);
@Select("SELECT item.account_id tenantId,item.inbound_id inboundId,"
+ "COALESCE(SUM(item.package_count),0) allocatedPackageCount,MAX(inbound.process_status) processStatus "
+ "FROM fl_loading_plan_item item JOIN fl_inbound_order inbound "
+ "ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id AND inbound.deleted=0 "
+ "WHERE item.loading_plan_id=#{loadingPlanId} AND item.deleted=0 "
+ "GROUP BY item.account_id,item.inbound_id HAVING SUM(item.package_count)>0")
@Select("""
SELECT item.account_id tenantId,item.inbound_id inboundId,
COALESCE(SUM(CASE WHEN item.pallet_id IS NULL THEN item.package_count ELSE 0 END),0)
+ COALESCE((SELECT COUNT(DISTINCT carton.id)
FROM fl_dropship_pallet_carton carton
WHERE carton.tenant_id=item.account_id AND carton.inbound_id=item.inbound_id
AND EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.loading_plan_id=#{loadingPlanId}
AND pallet_item.account_id=item.account_id
AND pallet_item.inbound_id=item.inbound_id
AND pallet_item.pallet_id=carton.pallet_id
AND pallet_item.deleted=0)),0) allocatedPackageCount,
MAX(inbound.process_status) processStatus
FROM fl_loading_plan_item item
JOIN fl_inbound_order inbound ON inbound.tenant_id=item.account_id
AND inbound.id=item.inbound_id AND inbound.deleted=0
WHERE item.loading_plan_id=#{loadingPlanId} AND item.deleted=0
GROUP BY item.account_id,item.inbound_id
HAVING allocatedPackageCount>0
""")
List<AllocationScope> allocatedScopes(@Param("loadingPlanId") long loadingPlanId);
@Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
@@ -76,9 +102,15 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
preload.version preloadVersion,link.loading_plan_id loadingPlanId,
plan.container_booking_id containerBookingId,booking.container_no containerNo,
plan.plan_status loadingPlanStatus,plan.version loadingPlanVersion,
COALESCE((SELECT SUM(item.package_count) FROM fl_loading_plan_item item
WHERE item.loading_plan_id=link.loading_plan_id AND item.account_id=link.tenant_id
AND item.inbound_id=link.inbound_id AND item.deleted=0),0) allocatedPackageCount,
COALESCE((SELECT SUM(CASE WHEN item.pallet_id IS NULL THEN item.package_count ELSE 0 END)
FROM fl_loading_plan_item item WHERE item.loading_plan_id=link.loading_plan_id
AND item.account_id=link.tenant_id AND item.inbound_id=link.inbound_id AND item.deleted=0),0)
+ COALESCE((SELECT COUNT(DISTINCT carton.id) FROM fl_dropship_pallet_carton carton
WHERE carton.tenant_id=link.tenant_id AND carton.inbound_id=link.inbound_id
AND EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item
WHERE pallet_item.loading_plan_id=link.loading_plan_id
AND pallet_item.account_id=link.tenant_id AND pallet_item.inbound_id=link.inbound_id
AND pallet_item.pallet_id=carton.pallet_id AND pallet_item.deleted=0)),0) allocatedPackageCount,
link.created_at linkedAt
FROM fl_preload_loading_plan_link link
JOIN fl_preload_plan preload ON preload.id=link.preload_plan_id
@@ -83,15 +83,19 @@ public interface LoadingCompletedEventMapper {
SELECT carton.tenant_id AS account_id,carton.inbound_id AS source_inbound_id,
pallet.id AS pallet_id,1 AS actual_binding,
pallet.pallet_no AS pallet_code,NULL AS allocation_no,
COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count,
carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code,
(SELECT COUNT(*) FROM fl_dropship_pallet_carton physical
WHERE physical.pallet_id=pallet.id AND physical.tenant_id=carton.tenant_id
AND physical.inbound_id=carton.inbound_id) AS allocated_package_count,
COALESCE(carton_sku.inbound_detail_id,carton.inbound_detail_id) AS source_inbound_detail_id,
COALESCE(carton_sku.sku_code,carton.sku_code) AS sku_code,
item.product_name_zh,item.package_count,item.product_quantity,carton.box_code,
carton.actual_sku_quantity
COALESCE(carton_sku.actual_quantity,carton.actual_sku_quantity) AS actual_sku_quantity
FROM fl_dropship_pallet pallet
JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id
LEFT JOIN fl_dropship_pallet_carton_sku carton_sku ON carton_sku.carton_id=carton.id
JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId}
AND item.account_id=carton.tenant_id AND item.inbound_id=carton.inbound_id
AND item.inbound_detail_id=carton.inbound_detail_id
AND item.inbound_detail_id=COALESCE(carton_sku.inbound_detail_id,carton.inbound_detail_id)
AND item.pallet_id=pallet.id AND item.deleted=0
WHERE pallet.pallet_status='SEALED'
ORDER BY carton.tenant_id,carton.inbound_id,pallet.id,item.inbound_detail_id,carton.id
@@ -83,14 +83,34 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
@Param("operatorName") String operatorName);
@Select("""
SELECT COALESCE(SUM(CASE
SELECT COALESCE(SUM(CASE WHEN plan_item.pallet_id IS NOT NULL THEN 0
WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0
WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0
AND pick_item.picked_package_count<=plan_item.package_count
THEN pick_item.picked_package_count
ELSE plan_item.package_count
END),0) expectedCount,
COALESCE(SUM(pick_item.picked_package_count),0) pickedCount,
END),0)
+ COALESCE((SELECT COUNT(DISTINCT carton.id)
FROM fl_dropship_pallet_carton carton
WHERE carton.tenant_id=#{tenantId} AND carton.inbound_id=#{inboundId}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_plan
WHERE pallet_plan.account_id=#{tenantId}
AND pallet_plan.inbound_id=#{inboundId}
AND pallet_plan.pallet_id=carton.pallet_id
AND pallet_plan.deleted=0)),0) expectedCount,
COALESCE(SUM(CASE WHEN plan_item.pallet_id IS NULL
THEN pick_item.picked_package_count ELSE 0 END),0)
+ COALESCE((SELECT COUNT(DISTINCT carton.id)
FROM fl_dropship_pallet_carton carton
WHERE carton.tenant_id=#{tenantId} AND carton.inbound_id=#{inboundId}
AND EXISTS (SELECT 1 FROM fl_loading_plan_item picked_plan
JOIN fl_loading_pick_item picked_item
ON picked_item.loading_plan_item_id=picked_plan.id
WHERE picked_plan.account_id=#{tenantId}
AND picked_plan.inbound_id=#{inboundId}
AND picked_plan.pallet_id=carton.pallet_id
AND picked_plan.deleted=0
AND picked_item.item_status='PICKED')),0) pickedCount,
COUNT(DISTINCT pick_task.id) taskCount,
COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount,
SUM(CASE
@@ -88,16 +88,23 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
WHEN task.task_status = 'COMPLETING' THEN 'PROCESSING'
WHEN task.task_status = 'COMPLETION_FAILED' THEN 'COMPLETION_FAILED'
WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED'
WHEN SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) = 0
WHEN COUNT(DISTINCT CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING')
THEN CASE WHEN plan_item.pallet_id IS NULL THEN CONCAT('I:',pick_item.id)
ELSE CONCAT('P:',plan_item.pallet_id) END END) = 0
THEN 'AWAITING_REVIEW'
ELSE 'IN_PROGRESS' END AS workflowStatus,
task.version AS version, COALESCE(assignment.version, 0) AS assignmentVersion,
COUNT(pick_item.id) AS itemCount,
SUM(CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING') THEN 1 ELSE 0 END) AS pendingItemCount
COUNT(DISTINCT CASE WHEN plan_item.pallet_id IS NULL THEN CONCAT('I:',pick_item.id)
ELSE CONCAT('P:',plan_item.pallet_id) END) AS itemCount,
COUNT(DISTINCT CASE WHEN pick_item.item_status IN ('PENDING', 'PICKING')
THEN CASE WHEN plan_item.pallet_id IS NULL THEN CONCAT('I:',pick_item.id)
ELSE CONCAT('P:',plan_item.pallet_id) END END) AS pendingItemCount
FROM fl_loading_pick_task task
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id
AND plan_item.loading_plan_id=plan.id AND plan_item.deleted=0
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE'
AND booking.warehouse_id = #{warehouseId}
@@ -175,10 +175,12 @@ public class LoadingCompletedOutboxService {
}
Integer frozenPalletCount = dropship(fulfillmentType) || standardLabels.isEmpty()
? actualPalletCount(palletRows) : Integer.valueOf(standardLabels.size());
int physicalPackageCount = dropship(fulfillmentType)
? physicalPackageCount(palletRows) : sumInt(sorted, Row::getPackageCount);
return new FirstLegLoadingPlanCompletedV2.Order(string(first.getSourceInboundId()),
string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(),
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), physicalPackageCount,
sumNullableInt(sorted, Row::getProductQuantity), frozenPalletCount,
sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), sum(sorted, Row::getDimensionalWeightKg), items,
@@ -346,7 +348,7 @@ public class LoadingCompletedOutboxService {
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
PalletRow::getPalletCode, LinkedHashMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> result = new ArrayList<>();
int actualPackages = 0;
Set<String> actualPackages = new HashSet<>();
for (Map.Entry<String, List<PalletRow>> pallet : byPallet.entrySet()) {
Map<Long, List<PalletRow>> byDetail = pallet.getValue().stream().collect(
java.util.stream.Collectors.groupingBy(PalletRow::getSourceInboundDetailId,
@@ -365,14 +367,14 @@ public class LoadingCompletedOutboxService {
if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量");
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(),
detail.getProductNameZh(), marks.size(), quantity, marks, cartonQuantities(detailRows.getValue(), marks)));
actualPackages += marks.size();
marks.forEach(mark -> actualPackages.add(pallet.getKey() + '|' + mark));
}
PalletManifest manifest = manifests.get(pallet.getKey());
if (manifest == null) throw new BizException(ResultCode.CONFLICT, "一件代发托盘缺少完整清单摘要");
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus,
manifest.version(), manifest.packageCount(), manifest.fragmentCount(), manifest.hash()));
}
if (actualPackages != sumInt(values, Row::getPackageCount)) {
if (actualPackages.size() != physicalPackageCount(source)) {
throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致");
}
return result;
@@ -390,13 +392,21 @@ public class LoadingCompletedOutboxService {
String canonical = sorted.stream().map(value -> value.getAccountId() + "|" + value.getSourceInboundId()
+ "|" + value.getSourceInboundDetailId() + "|" + value.getSkuCode() + "|" + value.getBoxCode()
+ "|" + value.getActualSkuQuantity()).collect(java.util.stream.Collectors.joining("\n"));
int fragments = (int) sorted.stream().map(PalletRow::getAccountId).distinct().count();
result.put(palletNo, new PalletManifest(1, sorted.size(), fragments,
int fragments = (int) sorted.stream().map(value -> value.getAccountId() + ":" + value.getSourceInboundId())
.distinct().count();
int packages = (int) sorted.stream().map(PalletRow::getBoxCode)
.filter(Objects::nonNull).distinct().count();
result.put(palletNo, new PalletManifest(1, packages, fragments,
manifestHash(canonical)));
});
return result;
}
private static int physicalPackageCount(List<PalletRow> rows) {
return (int) rows.stream().map(row -> row.getPalletCode() + '|' + row.getBoxCode())
.distinct().count();
}
private String manifestHash(String canonical) {
String value = EventPayloadHash.sha256(canonical.getBytes(StandardCharsets.UTF_8));
return value.substring("sha256:".length());
@@ -961,19 +961,7 @@ public class LoadingPickService {
.collect(Collectors.groupingBy(CartonLabelRow::getPickItemId));
Map<Long, Long> tenantByInbound = tenantByInbound(task.getId());
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(source, tenantByInbound);
List<PickItem> items = source.stream().map(item -> {
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
Long tenantId = first == null ? tenantByInbound.get(item.getInboundId()) : first.getTenantId();
return new PickItem(item.getId(), item.getLoadingPlanItemId(), tenantId,
item.getInboundId(),
item.getInboundDetailId(), item.getUnitType(), item.getPalletId(), item.getPalletNo(), item.getIndivisible(),
first == null ? null : first.getOrderNo(),
first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(),
cartons.stream().map(CartonLabelRow::getBoxCode).toList(),
inventoryByInbound.getOrDefault(item.getInboundId(), List.of()), item.getPlannedPackageCount(),
item.getPickedPackageCount(), item.getItemStatus(), item.getVersion());
}).toList();
List<PickItem> items = pickItems(source, cartonsByItem, tenantByInbound, inventoryByInbound);
LoadingPickAssignmentEntity assigned = repository.assignment(task.getId());
LoadingPickReviewEntity reviewed = repository.review(task.getId());
Assignment assignment = assigned == null ? null : new Assignment(assigned.getAssigneeId(),
@@ -1006,6 +994,71 @@ public class LoadingPickService {
workflowStatus, task.getVersion(), assignment, review, completionCommand, items);
}
/** One sealed pallet is one physical PDA work unit even when its cartons contain several SKU snapshots. */
private List<PickItem> pickItems(List<LoadingPickItemEntity> source,
Map<Long, List<CartonLabelRow>> cartonsByItem,
Map<Long, Long> tenantByInbound,
Map<Long, List<InventoryResource>> inventoryByInbound) {
Map<String, List<LoadingPickItemEntity>> units = source.stream().collect(Collectors.groupingBy(
item -> "SEALED_PALLET".equals(item.getUnitType()) && item.getPalletId() != null
? "P:" + item.getPalletId() : "I:" + item.getId(),
LinkedHashMap::new, Collectors.toList()));
Set<Long> palletIds = units.values().stream().map(items -> items.get(0))
.filter(item -> "SEALED_PALLET".equals(item.getUnitType()) && item.getPalletId() != null)
.map(LoadingPickItemEntity::getPalletId).collect(Collectors.toSet());
Map<Long, com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletMetrics> physical =
palletIds.isEmpty() ? Map.of() : dropshipPalletizationMapper.physicalMetricsByPalletIds(palletIds).stream()
.collect(Collectors.toMap(
com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletMetrics::palletId,
java.util.function.Function.identity()));
List<PickItem> result = new ArrayList<>();
for (List<LoadingPickItemEntity> members : units.values()) {
LoadingPickItemEntity firstItem = members.get(0);
List<CartonLabelRow> cartons = members.stream()
.flatMap(item -> cartonsByItem.getOrDefault(item.getId(), List.of()).stream()).toList();
CartonLabelRow firstCarton = cartons.isEmpty() ? null : cartons.get(0);
Long tenantId = firstCarton == null ? tenantByInbound.get(firstItem.getInboundId())
: firstCarton.getTenantId();
List<String> boxCodes = cartons.stream().map(CartonLabelRow::getBoxCode)
.filter(StringUtils::hasText).distinct().toList();
List<InventoryResource> inventory = members.stream()
.flatMap(item -> inventoryByInbound.getOrDefault(item.getInboundId(), List.of()).stream())
.collect(Collectors.collectingAndThen(Collectors.toMap(
resource -> resource.reservationId() == null ? "L:" + resource.locationId()
: "R:" + resource.reservationId(),
java.util.function.Function.identity(), (left, right) -> left, LinkedHashMap::new),
values -> List.copyOf(values.values())));
if (!"SEALED_PALLET".equals(firstItem.getUnitType()) || firstItem.getPalletId() == null) {
result.add(new PickItem(firstItem.getId(), firstItem.getLoadingPlanItemId(), tenantId,
firstItem.getInboundId(), firstItem.getInboundDetailId(), firstItem.getUnitType(),
firstItem.getPalletId(), firstItem.getPalletNo(), firstItem.getIndivisible(),
firstCarton == null ? null : firstCarton.getOrderNo(),
firstCarton == null ? null : firstCarton.getInboundCode(),
firstCarton == null ? null : firstCarton.getCargoName(), boxCodes, inventory,
firstItem.getPlannedPackageCount(), firstItem.getPickedPackageCount(),
firstItem.getItemStatus(), firstItem.getVersion()));
continue;
}
var metrics = physical.get(firstItem.getPalletId());
if (metrics == null || count(metrics.cartonCount()) < 1) {
throw conflict("托盘缺少物理箱明细,不能生成PDA整托任务");
}
boolean picked = members.stream().allMatch(item -> "PICKED".equals(item.getItemStatus()));
boolean pending = members.stream().allMatch(item -> "PENDING".equals(item.getItemStatus())
&& count(item.getPickedPackageCount()) == 0);
String status = picked ? "PICKED" : pending ? "PENDING" : "PICKING";
int version = members.stream().map(LoadingPickItemEntity::getVersion).filter(Objects::nonNull)
.max(Integer::compareTo).orElse(0);
result.add(new PickItem(firstItem.getId(), firstItem.getLoadingPlanItemId(), tenantId,
firstItem.getInboundId(), firstItem.getInboundDetailId(), "SEALED_PALLET",
firstItem.getPalletId(), firstItem.getPalletNo(), true,
firstCarton == null ? null : firstCarton.getOrderNo(),
firstCarton == null ? null : firstCarton.getInboundCode(), null, boxCodes, inventory,
metrics.cartonCount(), picked ? metrics.cartonCount() : 0, status, version));
}
return List.copyOf(result);
}
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
long taskId = task.getId();
List<LoadingPickItemEntity> allItems = repository.items(taskId);
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.MeasurementItemRow;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.ParentRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -50,6 +51,8 @@ public class PdaPickSettlementService {
private OrderFulfillmentSplitClient orderSplits;
@Resource
private InboundHoldGuard inboundHoldGuard;
@Resource
private DropshipPalletizationMapper dropshipPallets;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -173,13 +176,14 @@ public class PdaPickSettlementService {
if (measurements.stream().anyMatch(item -> !Objects.equals(sourceMeasurementId, item.measurementId()))) {
throw conflict("主入库单存在多个已审核测量批次,不能自动拆分");
}
Map<Long, Integer> pickedByDetail = pickedByDetail(taskItems, parentInboundId);
int pickedByMeasurement = pickedByDetail.values().stream().mapToInt(Integer::intValue).sum();
if (pickedByMeasurement != pickedPackageCount) {
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
}
boolean dropshipWholePallet = "ONE_CLICK_DROPSHIP".equals(
splits.fulfillmentType(parent.tenantId(), parentInboundId));
Map<Long, Integer> pickedByDetail = dropshipWholePallet
? Map.of() : pickedByDetail(taskItems, parentInboundId);
if (!dropshipWholePallet
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
}
if (dropshipWholePallet && childPackageCount != 0) {
throw conflict("一件代发现场托盘必须整票装柜,不能生成余量分片");
}
@@ -192,8 +196,7 @@ public class PdaPickSettlementService {
}
if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在");
Map<Long, Integer> orderQuantities = dropshipWholePallet
? new LinkedHashMap<>(pickedByDetail) : measurementQuantities(measurementLines);
Map<Long, Integer> orderQuantities = measurementQuantities(measurementLines);
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, orderQuantities,
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
"pda-task-" + taskId, operatorId, operatorName);
@@ -337,11 +340,35 @@ public class PdaPickSettlementService {
&& Objects.equals(child.containerBookingId(), containerBookingId);
}
private static Map<Long, Integer> pickedByInbound(List<LoadingPickItemEntity> taskItems) {
return taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|| "PARTIAL".equals(item.getItemStatus()))
.collect(Collectors.groupingBy(LoadingPickItemEntity::getInboundId,
Collectors.summingInt(item -> count(item.getPickedPackageCount(), "PDA已拣箱数"))));
private Map<Long, Integer> pickedByInbound(List<LoadingPickItemEntity> taskItems) {
Map<Long, Integer> result = new HashMap<>();
Set<Long> pickedPalletIds = new java.util.LinkedHashSet<>();
for (LoadingPickItemEntity item : taskItems) {
if (!("PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus()))) continue;
if ("SEALED_PALLET".equals(item.getUnitType()) && item.getPalletId() != null) {
pickedPalletIds.add(item.getPalletId());
} else {
result.merge(item.getInboundId(), count(item.getPickedPackageCount(), "PDA已拣箱数"), Integer::sum);
}
}
if (pickedPalletIds.isEmpty()) return result;
Map<Long, List<LoadingPickItemEntity>> palletItems = taskItems.stream()
.filter(item -> item.getPalletId() != null && pickedPalletIds.contains(item.getPalletId()))
.collect(Collectors.groupingBy(LoadingPickItemEntity::getPalletId));
if (palletItems.values().stream().anyMatch(items -> items.stream()
.anyMatch(item -> !"PICKED".equals(item.getItemStatus())))) {
throw conflict("一件代发整托存在未完成的 SKU 明细,不能结算物理箱数");
}
List<DropshipPalletizationMapper.PlanPalletMemberRow> physical =
dropshipPallets.palletInboundMembersByPalletIds(pickedPalletIds);
Set<Long> resolvedPallets = physical.stream().map(DropshipPalletizationMapper.PlanPalletMemberRow::getPalletId)
.collect(Collectors.toSet());
if (!resolvedPallets.equals(pickedPalletIds)) {
throw conflict("一件代发托盘缺少物理箱明细,不能完成装柜结算");
}
physical.forEach(row -> result.merge(row.getInboundId(),
count(row.getAvailablePackageCount(), "托盘物理箱数"), Integer::sum));
return result;
}
private static Map<Long, Integer> pickedByDetail(List<LoadingPickItemEntity> taskItems, long inboundId) {
@@ -13,6 +13,10 @@ class LoadingModelBridgeMapperContractTest {
.getMethod("allocatedPackages", long.class, long.class, long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
assertThat(sql).contains("inbound_id=#{inboundId} AND deleted=0");
assertThat(sql)
.contains("item.inbound_id=#{inboundId} AND item.deleted=0")
.contains("COUNT(DISTINCT carton.id)")
.contains("pallet_item.pallet_id=carton.pallet_id")
.contains("pallet_item.deleted=0");
}
}
@@ -300,6 +300,8 @@ class LoadingCompletedOutboxServiceTest {
assertThat(actualPalletsSql)
.contains("pallet.pallet_no AS pallet_code")
.contains("item.pallet_id=pallet.id")
.contains("LEFT JOIN fl_dropship_pallet_carton_sku carton_sku")
.contains("COALESCE(carton_sku.inbound_detail_id,carton.inbound_detail_id)")
.doesNotContain("pallet.pallet_code");
assertThat(workPalletCountSql).contains("i.pallet_id=p.id");
assertThat(openWorkPalletCountSql).contains("i.pallet_id=p.id");
@@ -374,6 +376,33 @@ class LoadingCompletedOutboxServiceTest {
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@Test
void multiSkuCartonsKeepSkuSnapshotsButCountPhysicalPackagesOnce() throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(2); second.setProductQuantity(10); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.workPalletCount(90L)).thenReturn(1);
when(snapshots.actualPalletRows(90L)).thenReturn(List.of(
actualPallet(11,100,"DP-260916-0009",101,"SKU-A","BOX-001",10),
actualPallet(11,100,"DP-260916-0009",102,"SKU-B","BOX-001",5),
actualPallet(11,100,"DP-260916-0009",101,"SKU-A","BOX-002",10),
actualPallet(11,100,"DP-260916-0009",102,"SKU-B","BOX-002",5)));
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(2);
assertThat(event.payload().at("/orders/0/items")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/manifestPackageCount").asInt()).isEqualTo(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/1/cartonMarks")).hasSize(2);
}
@Test void publishesExactPdaCartonToPalletBindingsWithoutSequentialRedistribution() throws Exception {
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
@@ -155,6 +155,9 @@ class LoadingPickServiceTest {
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(anyLong(), anyLong())).thenAnswer(invocation ->
new LoadingInboundMapper.Snapshot(invocation.getArgument(1), invocation.getArgument(0), "LOADING", 1));
when(dropshipPallets.physicalMetricsByPalletIds(any())).thenReturn(List.of(
new DropshipPalletizationMapper.PalletMetrics(801L, 2,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO)));
}
@Test
@@ -482,6 +485,39 @@ class LoadingPickServiceTest {
verify(completionCommands, never()).latest(20L);
}
@Test
void taskDetailCollapsesMultiSkuRowsIntoOnePhysicalPalletCard() {
LoadingPickItemEntity first = item();
LoadingPickItemEntity second = item();
second.setId(31L);
second.setLoadingPlanItemId(41L);
second.setInboundDetailId(61L);
for (LoadingPickItemEntity item : List.of(first, second)) {
item.setPlannedPackageCount(11);
item.setUnitType("SEALED_PALLET");
item.setPalletId(801L);
item.setPalletNo("DP-260916-0009");
item.setIndivisible(true);
}
when(dropshipPallets.physicalMetricsByPalletIds(any())).thenReturn(List.of(
new DropshipPalletizationMapper.PalletMetrics(801L, 11,
java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO)));
when(repository.task(20L)).thenReturn(task());
when(repository.items(20L, 101L)).thenReturn(List.of(first, second));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 20)));
Task result = service.task(20L, 101L);
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.palletNo()).isEqualTo("DP-260916-0009");
assertThat(item.plannedPackageCount()).isEqualTo(11);
assertThat(item.pickedPackageCount()).isZero();
});
}
@Test
void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() {
LoadingPickTaskEntity task = task();
@@ -9,6 +9,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.MeasurementItemRow;
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.ParentRow;
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import jakarta.annotation.Resource;
@@ -44,6 +45,7 @@ class PdaPickSettlementServiceTest {
@MockBean private WarehousePalletLocationClient warehouse;
@MockBean private OrderFulfillmentSplitClient orderSplits;
@MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private DropshipPalletizationMapper dropshipPallets;
@Resource private PdaPickSettlementService service;
@Test
@@ -205,7 +207,7 @@ class PdaPickSettlementServiceTest {
}
@Test
void settlesWholeDropshipPalletWithSkuFactsInsteadOfAggregateMeasurementDetail() {
void settlesWholeDropshipUsingUnifiedPhysicalCartonMeasurement() {
List<LoadingPickItemEntity> items = List.of(
item(11L, 21L, 31L, 41L, 1, 1, "PICKED"),
item(12L, 22L, 31L, 42L, 1, 1, "PICKED"),
@@ -223,14 +225,47 @@ class PdaPickSettlementServiceTest {
service.settle(1L, 9L, "operator");
verify(orderSplits).split(eq("internal-token"), eq(9L),
org.mockito.ArgumentMatchers.argThat(request -> request.lines().size() == 4
&& request.lines().stream().allMatch(line -> line.packageCount() == 1)
&& request.lines().stream().map(OrderFulfillmentSplitClient.QuantityLine::sourceDetailId)
.collect(java.util.stream.Collectors.toSet()).equals(java.util.Set.of(41L, 42L, 43L, 44L))));
org.mockito.ArgumentMatchers.argThat(request -> request.lines().size() == 1
&& request.lines().get(0).sourceDetailId() == 41L
&& request.lines().get(0).packageCount() == 4));
verify(splits).updateMeasurementItemForLoaded(10001L, 51L, 4);
verify(splits).markSourceLoaded(10001L, 31L, 4, 9L, 3);
}
@Test
void multiSkuCartonsCountOnceWhenSettlingWholeDropshipPallet() {
LoadingPickItemEntity first = item(11L, 21L, 31L, 41L, 20, 20, "PICKED");
LoadingPickItemEntity second = item(12L, 22L, 31L, 42L, 20, 20, "PICKED");
for (LoadingPickItemEntity item : List.of(first, second)) {
item.setUnitType("SEALED_PALLET");
item.setPalletId(801L);
item.setPalletNo("DP-260916-0009");
item.setIndivisible(true);
}
resolved(List.of(first, second));
DropshipPalletizationMapper.PlanPalletMemberRow physical =
new DropshipPalletizationMapper.PlanPalletMemberRow();
physical.setPalletId(801L);
physical.setAccountId(10001L);
physical.setInboundId(31L);
physical.setAvailablePackageCount(20);
when(dropshipPallets.palletInboundMembersByPalletIds(any())).thenReturn(List.of(physical));
when(splits.lockParent(31L)).thenReturn(parent(20));
when(splits.fulfillmentType(10001L, 31L)).thenReturn("ONE_CLICK_DROPSHIP");
when(splits.lockLatestApprovedItemsForInbound(10001L, 31L))
.thenReturn(List.of(new MeasurementItemRow(51L, 61L, 41L, 20)));
when(allocations.lockConfirmed(10001L, 31L))
.thenReturn(List.of(allocation(1L, 1, 501L, 20)));
writes(0, 20);
service.settle(1L, 9L, "operator");
verify(splits).markSourceLoaded(10001L, 31L, 20, 9L, 3);
verify(orderSplits).split(eq("internal-token"), eq(9L),
org.mockito.ArgumentMatchers.argThat(request -> request.loadedPackageCount() == 20
&& request.lines().size() == 1 && request.lines().get(0).packageCount() == 20));
}
@Test
void onlyPureSkippedInboundRestoresCandidate() {
LoadingPickItemEntity item = item(12L, 22L, 32L, 42L, 8, 0, "SKIPPED");
@@ -42,9 +42,11 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonSku;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
@@ -97,6 +99,7 @@ public class DeliveryTransferService {
@Resource private WarehouseTransferPalletMapper warehouseTransferPallets;
@Resource private DropshipOrderProfileMapper dropshipProfiles;
@Resource private DropshipCartonMapper dropshipCartons;
@Resource private DropshipCartonSkuMapper dropshipCartonSkus;
@Resource private DropshipSkuInventoryMapper dropshipSkuInventory;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private InboundPlanPalletAllocationMapper planAllocations;
@@ -396,6 +399,7 @@ public class DeliveryTransferService {
String warehouseId = null;
Map<String, Long> requestedPackages = new LinkedHashMap<>();
Map<String, DropshipOutboundCandidate> candidates = new HashMap<>();
Set<String> requestedMarks = new HashSet<>();
for (DeliveryRequests.OneClickItem input : items) {
String sku = input.sku().trim();
DropshipOutboundCandidate candidate = dropshipProfiles.lockOutboundCandidate(customerId,
@@ -414,8 +418,11 @@ public class DeliveryTransferService {
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");
}
if (input.cartonMarks().stream().map(String::trim).anyMatch(mark -> !requestedMarks.add(mark))) {
throw conflict("同一物理箱不能在一张出库单中重复选择");
}
List<DropshipCarton> selected = dropshipCartons.listProfile(customerId, candidate.getProfileId()).stream()
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && input.sku().trim().equals(carton.getSkuCode())
.filter(carton -> "AVAILABLE".equals(carton.getStatus())
&& input.cartonMarks().contains(carton.getCartonMark())).toList();
if (selected.size() != input.cartonMarks().size()) throw conflict("Specified carton marks are unavailable");
}
@@ -426,8 +433,8 @@ public class DeliveryTransferService {
if (requested.getValue() > value(candidate.getAvailablePackages())) {
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
}
List<DropshipCarton> cartons = dropshipCartons.lockAvailable(customerId,
candidate.getProfileId(), sku, requested.getValue());
List<DropshipCarton> cartons = dropshipCartons.lockAvailablePhysical(customerId,
candidate.getProfileId(), requested.getValue());
if (cartons.size() != requested.getValue()) {
throw conflict("一件代发可用箱数不足:" + candidate.getPalletNo() + " / " + sku);
}
@@ -560,19 +567,31 @@ public class DeliveryTransferService {
List<DropshipCarton> cartons = !selectedMarks.isEmpty()
? selectedMarks.stream()
.map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull)
.filter(carton -> "AVAILABLE".equals(carton.getStatus()) && item.getSkuCode().equals(carton.getSkuCode())).toList()
: dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages());
.filter(carton -> "AVAILABLE".equals(carton.getStatus())).toList()
: dropshipCartons.lockAvailablePhysical(customerId, profile.getId(), request.packages());
if (cartons.size() != request.packages()) throw conflict("Insufficient available one-click dropship cartons");
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":ALLOCATE", null);
inventoryCommands.allocate(customerId, operatorId, move);
inventoryCommands.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":PICK", null));
inventoryCommands.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.inventoryPositionId(), request.packages(), outbound.getOutboundNo(), outbound.getOutboundNo(), null, request.idempotencyKey() + ":SHIP", null));
long productQuantity = 0;
Map<String, SkuShipment> shippedSkus = new LinkedHashMap<>();
for (DropshipCarton carton : cartons) {
if (carton.getProductQuantity() == null || carton.getProductQuantity() <= 0) throw conflict("出库箱唛缺少逐箱实收件数");
productQuantity = Math.addExact(productQuantity, carton.getProductQuantity());
List<DropshipCartonSku> cartonFacts = dropshipCartonSkus.listCarton(customerId, carton.getId());
if (cartonFacts.isEmpty()) {
shippedSkus.merge(carton.getSkuCode(), new SkuShipment(1, carton.getProductQuantity()), SkuShipment::add);
} else {
for (DropshipCartonSku fact : cartonFacts) {
shippedSkus.merge(fact.getSkuCode(), new SkuShipment(1, value(fact.getProductQuantity())), SkuShipment::add);
}
}
}
for (Map.Entry<String, SkuShipment> shipment : shippedSkus.entrySet()) {
if (dropshipSkuInventory.deduct(customerId, profile.getId(), shipment.getKey(),
shipment.getValue().packages(), shipment.getValue().quantity()) != 1) {
throw conflict("Insufficient one-click dropship SKU inventory: " + shipment.getKey());
}
}
if (dropshipSkuInventory.deduct(customerId, profile.getId(), item.getSkuCode(), request.packages(), productQuantity) != 1) throw conflict("Insufficient one-click dropship SKU inventory");
for (DropshipCarton carton : cartons) { carton.setStatus("SHIPPED"); carton.setOutboundOrderNo(outbound.getOutboundNo()); carton.setVersion((carton.getVersion() == null ? 0 : carton.getVersion()) + 1); carton.setUpdatedAt(LocalDateTime.now()); dropshipCartons.updateById(carton); }
OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(request.inventoryPositionId()); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick);
item.setPickedPackageCount(value(item.getPickedPackageCount()) + request.packages().intValue()); item.setStatus(item.getPickedPackageCount() >= item.getPackageCount() ? "PICKED" : "PARTIAL_PICKED"); outboundPallets.updateById(item);
@@ -778,7 +797,6 @@ public class DeliveryTransferService {
DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, position.getId());
List<String> marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId())
.stream().filter(carton -> "AVAILABLE".equals(carton.getStatus()))
.filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode()))
.filter(carton -> !StringUtils.hasText(item.getCartonMarks()) || frozenMarks.contains(carton.getCartonMark()))
.map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList();
if (profile != null && marks.isEmpty()) continue;
@@ -983,6 +1001,12 @@ public class DeliveryTransferService {
}
private record PalletPickPlan(Long itemId, Long positionId, long packages,
List<String> cartonMarks, int sequence) {}
private record SkuShipment(long packages, long quantity) {
private SkuShipment add(SkuShipment other) {
return new SkuShipment(Math.addExact(packages, other.packages),
Math.addExact(quantity, other.quantity));
}
}
private BigDecimal totalWeight(String customerId, List<DispatchItem> items) {
List<Long> palletIds = items.stream().map(DispatchItem::getWarehousePalletId)
.filter(Objects::nonNull).distinct().toList();
@@ -17,8 +17,9 @@ public interface CargoDiscardInventoryMapper {
"COUNT(c.id) available_packages FROM ll_order o " +
"JOIN ll_dropship_order_profile p ON p.customer_id=o.customer_id AND p.order_id=o.id " +
"JOIN ll_dropship_sku_inventory s ON s.customer_id=o.customer_id AND s.order_id=o.id AND s.profile_id=p.id " +
"LEFT JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE' " +
"JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.status='AVAILABLE' " +
"JOIN ll_dropship_carton_sku cs ON cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=s.sku_code " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
@@ -26,21 +27,28 @@ public interface CargoDiscardInventoryMapper {
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE'")
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'")
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
"AND c.profile_id=#{profileId} AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
"AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE")
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) " +
"ORDER BY c.profile_id,c.carton_sequence,c.id LIMIT #{limit} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND status='AVAILABLE'")
long countAvailableOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@@ -7,9 +7,12 @@ import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipCartonSku;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import jakarta.annotation.Resource;
@@ -21,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
/** Final inventory mutation for a warehouse-approved cargo discard. */
@@ -28,6 +32,8 @@ import java.util.Map;
public class CargoDiscardInventoryAdjustmentService {
@Resource private CargoDiscardInventoryMapper inventory;
@Resource private DropshipCartonLedgerMapper cartonLedgers;
@Resource private DropshipCartonSkuMapper cartonSkus;
@Resource private DropshipSkuInventoryMapper skuInventories;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private InventoryPositionMapper positions;
@Resource private InventoryBalanceMapper balances;
@@ -40,8 +46,25 @@ public class CargoDiscardInventoryAdjustmentService {
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
if (sku == null) throw conflict("托盘 SKU 库存不存在: " + item.getSkuCode());
long before = value(sku.getPackageCount());
if (before < actualPackages || inventory.deductSkuInventory(customerId, sku.getId(), actualPackages) != 1) {
throw conflict("托盘 SKU 库存不足: " + item.getSkuCode());
Map<String, SkuDiscard> discardedSkus = new LinkedHashMap<>();
for (int index = 0; index < actualPackages; index++) {
CargoDiscardCarton link = links.get(index);
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
if (carton == null) throw conflict("托盘预留箱不存在");
List<DropshipCartonSku> facts = cartonSkus.listCarton(customerId, carton.getId());
if (facts.isEmpty()) {
discardedSkus.merge(carton.getSkuCode(), new SkuDiscard(1, value(carton.getProductQuantity())), SkuDiscard::add);
} else {
for (DropshipCartonSku fact : facts) {
discardedSkus.merge(fact.getSkuCode(), new SkuDiscard(1, value(fact.getProductQuantity())), SkuDiscard::add);
}
}
}
for (Map.Entry<String, SkuDiscard> discarded : discardedSkus.entrySet()) {
if (skuInventories.deduct(customerId, item.getProfileId(), discarded.getKey(),
discarded.getValue().packages(), discarded.getValue().quantity()) != 1) {
throw conflict("托盘 SKU 库存不足: " + discarded.getKey());
}
}
var profile = profiles.selectById(item.getProfileId());
var position = profile == null || profile.getInventoryPositionId() == null ? null
@@ -120,6 +143,11 @@ public class CargoDiscardInventoryAdjustmentService {
}
private static long value(Long value) { return value == null ? 0L : value; }
private record SkuDiscard(long packages, long quantity) {
private SkuDiscard add(SkuDiscard other) {
return new SkuDiscard(Math.addExact(packages, other.packages), Math.addExact(quantity, other.quantity));
}
}
private static String remarkSuffix(String value) {
return value == null || value.isBlank() ? "" : "; " + value.trim();
}
@@ -75,7 +75,9 @@ public class CargoDiscardService {
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
value.add(row);
}
return grouped.values().stream().map(CandidateAccumulator::response).toList();
return grouped.values().stream()
.map(value -> value.response(inventory.countAvailableOrder(customerId, value.first.getOrderId())))
.toList();
}
@Transactional
@@ -396,12 +398,11 @@ public class CargoDiscardService {
skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName()))
.packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages();
}
private CargoDiscardResponses.Candidate response() {
private CargoDiscardResponses.Candidate response(long physicalPackages) {
List<CargoDiscardResponses.CandidateSku> skuViews = skus.values().stream()
.map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList();
long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(),
first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews);
first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), physicalPackages, skuViews);
}
}
private static final class CandidateSkuAccumulator {
@@ -0,0 +1,23 @@
package com.baiyue.lastleg.dropship.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/** SKU facts inside one physical dropship carton. */
@Data
@TableName("ll_dropship_carton_sku")
public class DropshipCartonSku {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long orderId;
private Long profileId;
private Long cartonId;
private String skuCode;
private String productName;
private Long productQuantity;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -27,6 +27,10 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
@Param("cartonMark") String cartonMark);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND sku_code=#{sku} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("profileId") Long profileId, @Param("sku") String sku, @Param("packages") long packages);
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} AND status='AVAILABLE' ORDER BY carton_sequence LIMIT #{packages} FOR UPDATE")
List<DropshipCarton> lockAvailablePhysical(@Param("customerId") String customerId,
@Param("profileId") Long profileId,
@Param("packages") long packages);
@Update("UPDATE ll_dropship_carton SET status='AVAILABLE', outbound_order_no=NULL, updated_at=NOW(3) WHERE customer_id=#{customerId} AND outbound_order_no=#{outboundNo} AND status='SHIPPED'")
int restoreOutbound(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.dropship.mapper;
import com.baiyue.lastleg.dropship.entity.DropshipCartonSku;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DropshipCartonSkuMapper extends BaseMapper<DropshipCartonSku> {
@Select("SELECT * FROM ll_dropship_carton_sku WHERE customer_id=#{customerId} AND carton_id=#{cartonId} ORDER BY id")
List<DropshipCartonSku> listCarton(@Param("customerId") String customerId,
@Param("cartonId") Long cartonId);
}
@@ -10,21 +10,24 @@ import java.util.List;
public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProfile> {
String OUTBOUND_CANDIDATE_SELECT = "SELECT pos.id inventory_position_id,p.id profile_id," +
"p.warehouse_pallet_id,pos.overseas_warehouse_id,b.overseas_warehouse_name," +
"p.pallet_no,b.inbound_code,o.order_no,o.customer_id,o.customer_name,s.sku_code,s.product_name," +
"LEAST(pos.available_packages,s.package_count,(SELECT COUNT(*) FROM ll_dropship_carton c " +
"WHERE c.customer_id=p.customer_id AND c.profile_id=p.id AND c.sku_code=s.sku_code " +
"p.pallet_no,b.inbound_code,o.order_no,o.customer_id,o.customer_name," +
"(SELECT CASE WHEN COUNT(*)=1 THEN MAX(s.sku_code) ELSE 'MIXED' END FROM ll_dropship_sku_inventory s " +
"WHERE s.customer_id=p.customer_id AND s.profile_id=p.id) sku_code," +
"(SELECT CASE WHEN COUNT(*)=1 THEN MAX(s.product_name) ELSE '混合SKU' END FROM ll_dropship_sku_inventory s " +
"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 " +
"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 " +
"AND pos.pallet_id=p.warehouse_pallet_id " +
"JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
"JOIN ll_dropship_sku_inventory s ON s.customer_id=p.customer_id AND s.order_id=p.order_id " +
"AND s.profile_id=p.id ";
"";
String OUTBOUND_CANDIDATE_WHERE = "WHERE (#{customerId} IS NULL OR #{customerId}='' OR p.customer_id=#{customerId}) AND p.status='ACTIVE' " +
"AND pos.status='OCCUPIED' AND pos.available_packages>0 AND s.package_count>0 " +
"AND pos.status='OCCUPIED' AND pos.available_packages>0 " +
"AND EXISTS (SELECT 1 FROM ll_dropship_carton c WHERE c.customer_id=p.customer_id " +
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE') " +
"AND c.profile_id=p.id AND c.status='AVAILABLE') " +
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock dl WHERE dl.customer_id=p.customer_id " +
"AND dl.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id) AND dl.status='ACTIVE' " +
"AND (dl.expires_at IS NULL OR dl.expires_at>CURRENT_TIMESTAMP(3))) " +
@@ -33,7 +36,7 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
"AND NOT EXISTS (SELECT 1 FROM ll_outbound_pallet_item oi JOIN ll_outbound_order oo " +
"ON oo.id=oi.outbound_order_id AND oo.customer_id=oi.customer_id " +
"WHERE oi.customer_id=p.customer_id AND oi.warehouse_pallet_id=p.warehouse_pallet_id " +
"AND oi.inbound_code=b.inbound_code AND oi.sku_code=s.sku_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
"AND oi.inbound_code=b.inbound_code AND oo.status IN ('WAIT_PICK','PICKING','PICKED')) ";
@Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no")
List<DropshipOrderProfile> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@@ -50,12 +53,15 @@ public interface DropshipOrderProfileMapper extends BaseMapper<DropshipOrderProf
@Select("<script>" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"<if test='warehouseId != null and warehouseId != \"\"'>AND pos.overseas_warehouse_id=#{warehouseId} </if>" +
"ORDER BY b.overseas_warehouse_name,p.pallet_no,s.sku_code LIMIT 500</script>")
"ORDER BY b.overseas_warehouse_name,p.pallet_no,sku_code LIMIT 500</script>")
List<DropshipOutboundCandidate> listOutboundCandidates(@Param("customerId") String customerId,
@Param("warehouseId") String warehouseId);
@Select(OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE +
"AND pos.id=#{positionId} AND s.sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
"AND pos.id=#{positionId} " +
"AND #{skuCode}=(SELECT CASE WHEN COUNT(*)=1 THEN MAX(s.sku_code) ELSE 'MIXED' END " +
"FROM ll_dropship_sku_inventory s WHERE s.customer_id=p.customer_id AND s.profile_id=p.id) " +
"LIMIT 1 FOR UPDATE")
DropshipOutboundCandidate lockOutboundCandidate(@Param("customerId") String customerId,
@Param("positionId") Long positionId,
@Param("skuCode") String skuCode);
@@ -27,6 +27,7 @@ public class DropshipOrderService {
@Resource private DropshipOrderProfileMapper profiles;
@Resource private DropshipSkuInventoryMapper skuInventory;
@Resource private DropshipCartonMapper cartons;
@Resource private DropshipCartonSkuMapper cartonSkus;
@Resource private DropshipCartonLedgerMapper ledger;
@Transactional
@@ -34,8 +35,9 @@ public class DropshipOrderService {
LastlegOrder order = orders.selectById(request.orderId());
if (order == null || !request.customerId().equals(order.getCustomerId())) throw conflict("Tail order not found");
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) throw conflict("Order is not a one-click dropship order");
long totalPackages = request.pallets().stream().flatMap(value -> value.skuPackages().stream())
.mapToLong(DropshipRequests.SkuPackages::packageCount).sum();
validateMarks(request);
long totalPackages = request.pallets().stream()
.mapToLong(DropshipRequests.Pallet::manifestPackageCount).sum();
long totalQuantity = request.pallets().stream().flatMap(value -> value.skuPackages().stream())
.mapToLong(DropshipRequests.SkuPackages::productQuantity).sum();
if (order.getPackageCount() != null && order.getPackageCount().longValue() != totalPackages) {
@@ -65,12 +67,11 @@ public class DropshipOrderService {
return Math.toIntExact(totalPackages);
}
validateMarks(request);
LocalDateTime now = LocalDateTime.now();
for (DropshipRequests.Pallet inputPallet : request.pallets()) {
DropshipOrderProfile profile = profile(request, order, inputPallet, now);
profiles.insert(profile);
int sequence = 0;
Map<String, List<CartonSkuFact>> factsByMark = new java.util.LinkedHashMap<>();
for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) {
DropshipSkuInventory inventory = new DropshipSkuInventory();
inventory.setId(IdWorker.getId()); inventory.setCustomerId(request.customerId()); inventory.setOrderId(order.getId());
@@ -79,26 +80,43 @@ public class DropshipOrderService {
inventory.setCreatedAt(now); inventory.setUpdatedAt(now);
skuInventory.insert(inventory);
for (String mark : input.cartonMarks()) {
DropshipCarton carton = new DropshipCarton();
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
carton.setProductQuantity(input.cartonQuantities().get(mark));
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
ledger.insert(stockInFact(request, order, carton, now));
factsByMark.computeIfAbsent(mark.trim(), ignored -> new java.util.ArrayList<>())
.add(new CartonSkuFact(input.skuCode().trim(), input.productName(),
input.cartonQuantities().get(mark)));
}
}
int sequence = 0;
for (Map.Entry<String, List<CartonSkuFact>> entry : factsByMark.entrySet()) {
List<CartonSkuFact> facts = entry.getValue();
DropshipCarton carton = new DropshipCarton();
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
carton.setSkuCode(facts.size() == 1 ? facts.get(0).skuCode() : "MIXED");
carton.setCartonMark(entry.getKey()); carton.setCartonSequence(++sequence);
carton.setProductQuantity(facts.stream().mapToLong(CartonSkuFact::quantity).sum());
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
cartons.insert(carton);
for (CartonSkuFact fact : facts) {
DropshipCartonSku sku = new DropshipCartonSku();
sku.setId(IdWorker.getId()); sku.setCustomerId(request.customerId()); sku.setOrderId(order.getId());
sku.setProfileId(profile.getId()); sku.setCartonId(carton.getId()); sku.setSkuCode(fact.skuCode());
sku.setProductName(fact.productName()); sku.setProductQuantity(fact.quantity());
sku.setCreatedAt(now); sku.setUpdatedAt(now); cartonSkus.insert(sku);
}
ledger.insert(stockInFact(request, order, carton, now));
}
}
return Math.toIntExact(totalPackages);
}
private void validateMarks(DropshipRequests.Receive request) {
Set<String> marks = new HashSet<>();
Map<String, String> palletByMark = new java.util.HashMap<>();
Set<String> palletNos = new HashSet<>();
for (DropshipRequests.Pallet pallet : request.pallets()) {
if (!palletNos.add(pallet.palletNo().trim())) throw conflict("Duplicate pallet number: " + pallet.palletNo());
String palletNo = pallet.palletNo().trim();
if (!palletNos.add(palletNo)) throw conflict("Duplicate pallet number: " + pallet.palletNo());
Set<String> skus = new HashSet<>();
Set<String> physicalMarks = new HashSet<>();
for (DropshipRequests.SkuPackages item : pallet.skuPackages()) {
if (item.packageCount() != item.cartonMarks().size()) {
throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode());
@@ -110,10 +128,20 @@ public class DropshipOrderService {
throw conflict("逐箱实收件数必须完整且与 SKU 汇总件数一致");
}
if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode());
Set<String> skuMarks = new HashSet<>();
for (String mark : item.cartonMarks()) {
if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark);
String normalized = mark.trim();
if (!skuMarks.add(normalized)) throw conflict("Duplicate carton mark for SKU: " + mark);
String existingPallet = palletByMark.putIfAbsent(normalized, palletNo);
if (existingPallet != null && !existingPallet.equals(palletNo)) {
throw conflict("Carton mark belongs to multiple pallets: " + mark);
}
physicalMarks.add(normalized);
}
}
if (physicalMarks.size() != pallet.manifestPackageCount()) {
throw conflict("Pallet physical carton total does not equal manifest package count: " + palletNo);
}
}
}
@@ -143,7 +171,7 @@ public class DropshipOrderService {
value(sku.getPackageCount()), value(sku.getProductQuantity())))
.toList();
return new DropshipPalletSnapshot(profile.getSourcePalletNo(),
skus.stream().mapToLong(DropshipPalletSnapshot.Sku::packageCount).sum(), skus);
value(profile.getManifestPackageCount()), skus);
}).toList();
}
@@ -213,6 +241,9 @@ public class DropshipOrderService {
}
private static long value(Long value) { return value == null ? 0L : value; }
private static long value(Integer value) { return value == null ? 0L : value.longValue(); }
private record CartonSkuFact(String skuCode, String productName, long quantity) {}
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
@@ -120,12 +120,14 @@ public class InboundStockReceiptService {
}
DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getSourcePalletNo());
if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) {
throw conflict("One-click dropship source pallet SKU package total does not match PDA receipt");
}
for (DropshipPalletSnapshot.Sku sku : sourcePallet.skus()) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
new SkuSnapshot(sku.skuCode(), sku.productName(), null, sku.packageCount()), receivedAt));
throw conflict("One-click dropship source pallet physical carton total does not match PDA receipt");
}
DropshipPalletSnapshot.Sku single = sourcePallet.skus().size() == 1
? sourcePallet.skus().get(0) : null;
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
new SkuSnapshot(single == null ? "MIXED" : single.skuCode(),
single == null ? "混合SKU" : single.productName(), null,
sourcePallet.packageCount()), receivedAt));
} else {
long allocationPackages = value(allocation.getPickedPackageCount());
for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) {
@@ -219,9 +219,22 @@ public class LoadingCompletedConsumer {
FirstLegLoadingPlanCompletedV1.Order order = payload.orders().get(index);
Set<String> orderCartonMarks = new HashSet<>();
if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) {
for (FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet : order.pallets()) {
JsonNode rawPallets = rawPayload.path("orders").get(index).path("pallets");
for (int palletIndex = 0; palletIndex < order.pallets().size(); palletIndex++) {
FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet = order.pallets().get(palletIndex);
Set<String> palletCartonMarks = new HashSet<>();
for (FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot sku : pallet.skuPackages()) {
requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks);
requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), new HashSet<>());
palletCartonMarks.addAll(sku.cartonMarks());
}
int manifestPackages = rawPallets.path(palletIndex).path("manifestPackageCount").asInt(-1);
if (palletCartonMarks.size() != manifestPackages) {
throw new IllegalArgumentException("一件代发托盘物理箱数与清单箱数不一致: " + pallet.palletNo());
}
for (String mark : palletCartonMarks) {
if (!orderCartonMarks.add(mark)) {
throw new IllegalArgumentException("一件代发物理箱不能跨托盘重复: " + mark);
}
}
}
dropshipReceivingLabels(order, rawPayload.path("orders").get(index));
@@ -1917,6 +1917,18 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton (
KEY idx_ll_dropship_carton_status(customer_id,status), KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status)
) ENGINE=InnoDB COMMENT='一件代发箱唛明细';
CREATE TABLE IF NOT EXISTS ll_dropship_carton_sku (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID',
profile_id BIGINT NOT NULL COMMENT '一件代发托盘档案ID', carton_id BIGINT NOT NULL COMMENT '一件代发物理箱ID',
sku_code VARCHAR(128) NOT NULL COMMENT '箱内SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称快照',
product_quantity BIGINT NOT NULL COMMENT '该物理箱内此SKU实收件数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton_sku(carton_id,sku_code),
KEY idx_ll_dropship_carton_sku_order(customer_id,order_id,sku_code),
KEY idx_ll_dropship_carton_sku_profile(customer_id,profile_id,sku_code)
) ENGINE=InnoDB COMMENT='一件代发物理箱内SKU明细';
CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT NULL COMMENT '产品数量',
@@ -13,20 +13,22 @@ class DropshipOutboundCandidateMapperContractTest {
String sql = select("listOutboundCandidates", String.class, String.class);
for (String token : new String[] {"p.customer_id=#{customerId}", "p.status='ACTIVE'",
"o.order_type='ONE_CLICK_DROPSHIP'", "COALESCE(o.detained,0)=0", "pos.status='OCCUPIED'",
"pos.available_packages>0", "s.package_count>0", "c.status='AVAILABLE'",
"pos.available_packages>0", "c.status='AVAILABLE'", "COUNT(*)=1", "ELSE 'MIXED'",
"ll_dispatch_pallet_lock", "ll_warehouse_transfer_inventory_allocation",
"ll_outbound_pallet_item", "pos.overseas_warehouse_id=#{warehouseId}", "LIMIT 500"}) {
assertTrue(sql.contains(token), token);
}
assertTrue(sql.contains("LEAST(pos.available_packages,s.package_count"));
assertTrue(sql.contains("LEAST(pos.available_packages,(SELECT COUNT(*) FROM ll_dropship_carton"));
assertTrue(sql.contains("#{customerId} IS NULL OR #{customerId}='' OR p.customer_id=#{customerId}"));
assertTrue(sql.contains("ORDER BY b.overseas_warehouse_name,p.pallet_no,sku_code"));
assertTrue(!sql.contains("ORDER BY b.overseas_warehouse_name,p.pallet_no,s.sku_code"));
}
@Test
void creationLockUsesTheSameEligibilityContractAndLocksOneSkuDimension() throws Exception {
void creationLockUsesTheSameEligibilityContractAndLocksOnePhysicalPalletDimension() throws Exception {
String sql = select("lockOutboundCandidate", String.class, Long.class, String.class);
assertTrue(sql.contains("AND pos.id=#{positionId}"));
assertTrue(sql.contains("AND s.sku_code=#{skuCode}"));
assertTrue(sql.contains("AND #{skuCode}=(SELECT CASE WHEN COUNT(*)=1"));
assertTrue(sql.contains("LIMIT 1 FOR UPDATE"));
assertTrue(sql.contains("ll_dispatch_pallet_lock"));
assertTrue(sql.contains("ll_outbound_pallet_item"));
@@ -34,8 +34,10 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferPalletMapper;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonSku;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.dropship.model.DropshipOutboundCandidate;
import org.junit.jupiter.api.BeforeEach;
@@ -83,6 +85,7 @@ class DeliveryTransferServiceTest {
@Mock InventoryBalanceMapper inventoryBalances;
@Mock DropshipOrderProfileMapper dropshipProfiles;
@Mock DropshipCartonMapper dropshipCartons;
@Mock DropshipCartonSkuMapper dropshipCartonSkus;
@Mock DropshipSkuInventoryMapper dropshipSkuInventory;
@Mock WarehouseTransferPalletMapper warehouseTransferPallets;
@@ -112,6 +115,7 @@ class DeliveryTransferServiceTest {
ReflectionTestUtils.setField(service, "inventoryBalances", inventoryBalances);
ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles);
ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons);
ReflectionTestUtils.setField(service, "dropshipCartonSkus", dropshipCartonSkus);
ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory);
ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets);
}
@@ -458,7 +462,7 @@ class DeliveryTransferServiceTest {
for (long i = 1; i <= 2; i++) {
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 900L + i, "SKU-1"))
.thenReturn(dropshipCandidate(900L + i, 1000L + i, "WH-1", "DP-SHARED", "SKU-1", 1L));
when(dropshipCartons.lockAvailable("TENANT-A", 1000L + i, "SKU-1", 1L))
when(dropshipCartons.lockAvailablePhysical("TENANT-A", 1000L + i, 1L))
.thenReturn(List.of(new DropshipCarton()));
InventoryPosition position = new InventoryPosition();
position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i);
@@ -489,7 +493,7 @@ class DeliveryTransferServiceTest {
when(inventoryPositions.selectById(901L)).thenReturn(position);
DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L);
when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(candidate);
when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "MIXED", 1L)).thenReturn(List.of());
when(dropshipCartons.lockAvailablePhysical("TENANT-A", 1001L, 1L)).thenReturn(List.of());
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick(
"TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of(
@@ -807,15 +811,15 @@ class DeliveryTransferServiceTest {
OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A");
task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING");
OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2);
item.setSkuCode("SKU-1"); item.setCartonMarks("BOX-A,BOX-B");
item.setSkuCode("MIXED"); item.setCartonMarks("BOX-A,BOX-B");
InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A");
position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1");
InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A");
DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L);
DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L);
DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L);
first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("BOX-A");
second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("BOX-B");
first.setStatus("AVAILABLE"); first.setSkuCode("MIXED"); first.setCartonMark("BOX-A");
second.setStatus("AVAILABLE"); second.setSkuCode("MIXED"); second.setCartonMark("BOX-B");
when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task);
when(outboundOrders.selectById(301L)).thenReturn(outbound);
when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound);
@@ -825,8 +829,14 @@ class DeliveryTransferServiceTest {
when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile);
when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-A")).thenReturn(first);
when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-B")).thenReturn(second);
when(dropshipCartonSkus.listCarton("TENANT-A", 1101L)).thenReturn(List.of(
cartonSku("SKU-1", 7), cartonSku("SKU-2", 3)));
when(dropshipCartonSkus.listCarton("TENANT-A", 1102L)).thenReturn(List.of(
cartonSku("SKU-1", 13), cartonSku("SKU-2", 2)));
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1);
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-2", 1L, 3L)).thenReturn(1);
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1);
when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-2", 1L, 2L)).thenReturn(1);
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701",
new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "wrong-mark", List.of("WRONG-BOX"))));
@@ -837,7 +847,9 @@ class DeliveryTransferServiceTest {
new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "repeat-box-new-key", List.of("BOX-A"))));
service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13", List.of("BOX-B")));
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L);
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-2", 1L, 3L);
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L);
verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-2", 1L, 2L);
assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus());
assertEquals(2, item.getPickedPackageCount());
}
@@ -962,4 +974,10 @@ class DeliveryTransferServiceTest {
value.setOverseasWarehouseId(warehouseId); value.setPalletNo(palletNo); value.setSkuCode(sku);
value.setAvailablePackages(packages); return value;
}
private static DropshipCartonSku cartonSku(String sku, long quantity) {
DropshipCartonSku value = new DropshipCartonSku();
value.setSkuCode(sku); value.setProductQuantity(quantity);
return value;
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.discard;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CargoDiscardPhysicalCartonMapperContractTest {
@Test
void candidatesJoinSkuFactsButCountAvailableOrdersPhysically() throws Exception {
String candidates = select("candidates", String.class, String.class);
assertTrue(candidates.contains("JOIN ll_dropship_carton_sku cs"));
assertTrue(candidates.contains("cs.carton_id=c.id"));
assertTrue(candidates.contains("COUNT(c.id) available_packages"));
String physicalTotal = select("countAvailableOrder", String.class, Long.class);
assertTrue(physicalTotal.contains("COUNT(*) FROM ll_dropship_carton"));
assertTrue(!physicalTotal.contains("ll_dropship_carton_sku"));
}
@Test
void skuSelectionResolvesThroughPhysicalCartonSkuFacts() throws Exception {
String count = select("countAvailable", String.class, Long.class, String.class);
String lock = select("lockAvailable", String.class, Long.class, String.class, int.class);
for (String sql : new String[] {count, lock}) {
assertTrue(sql.contains("EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs"));
assertTrue(sql.contains("cs.carton_id=c.id"));
assertTrue(sql.contains("cs.sku_code=#{skuCode}"));
}
assertTrue(lock.contains("FOR UPDATE"));
}
private String select(String method, Class<?>... parameterTypes) throws Exception {
Select annotation = CargoDiscardInventoryMapper.class.getMethod(method, parameterTypes)
.getAnnotation(Select.class);
assertNotNull(annotation);
return String.join("", annotation.value());
}
}
@@ -6,8 +6,16 @@ import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipCartonSku;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,6 +45,11 @@ import static org.mockito.Mockito.when;
class CargoDiscardInventoryAdjustmentServiceTest {
@Mock CargoDiscardInventoryMapper inventory;
@Mock DropshipCartonLedgerMapper ledgers;
@Mock DropshipCartonSkuMapper cartonSkus;
@Mock DropshipSkuInventoryMapper skuInventories;
@Mock DropshipOrderProfileMapper profiles;
@Mock InventoryPositionMapper positions;
@Mock InventoryBalanceMapper balances;
private CargoDiscardInventoryAdjustmentService service;
@BeforeEach
@@ -44,6 +57,45 @@ class CargoDiscardInventoryAdjustmentServiceTest {
service = new CargoDiscardInventoryAdjustmentService();
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "cartonLedgers", ledgers);
ReflectionTestUtils.setField(service, "cartonSkus", cartonSkus);
ReflectionTestUtils.setField(service, "skuInventories", skuInventories);
ReflectionTestUtils.setField(service, "profiles", profiles);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "balances", balances);
}
@Test
void pdaDiscardDeductsEverySkuInsideOnePhysicalCartonOnce() {
CargoDiscardApplication application = application(1);
CargoDiscardItem item = item(1);
item.setProfileId(201L);
DropshipCarton carton = carton(1L);
carton.setSkuCode("MIXED"); carton.setProductQuantity(5L);
DropshipCartonSku skuA = cartonSku("SKU-A", 2L);
DropshipCartonSku skuB = cartonSku("SKU-B", 3L);
DropshipOrderProfile profile = new DropshipOrderProfile();
profile.setId(201L); profile.setCustomerId("100"); profile.setInventoryPositionId(801L);
InventoryPosition position = new InventoryPosition();
position.setId(801L); position.setBalanceId(901L);
when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5));
when(inventory.lockCarton("100", 1L)).thenReturn(carton);
when(cartonSkus.listCarton("100", 1L)).thenReturn(List.of(skuA, skuB));
when(skuInventories.deduct("100", 201L, "SKU-A", 1, 2)).thenReturn(1);
when(skuInventories.deduct("100", 201L, "SKU-B", 1, 3)).thenReturn(1);
when(profiles.selectById(201L)).thenReturn(profile);
when(positions.findTenantPosition("100", 801L)).thenReturn(position);
when(positions.deductAvailable("100", 801L, 1)).thenReturn(1);
when(balances.deductAvailable("100", 901L, 1)).thenReturn(1);
when(inventory.changeCartonStatus("100", 1L, "DISCARD_PENDING", "DISCARDED")).thenReturn(1);
when(inventory.skuPackages("100", 301L)).thenReturn(4L);
var result = service.discardPalletItem("100", application, item, List.of(link(1L)),
1, "operator", LocalDateTime.now());
assertEquals(1, result.discardedPackages());
verify(skuInventories).deduct("100", 201L, "SKU-A", 1, 2);
verify(skuInventories).deduct("100", 201L, "SKU-B", 1, 3);
verify(positions).deductAvailable("100", 801L, 1);
}
@Test
@@ -105,4 +157,8 @@ class CargoDiscardInventoryAdjustmentServiceTest {
private DropshipCarton carton(long id) {
DropshipCarton value = new DropshipCarton(); value.setId(id); value.setCartonMark("C-" + id); return value;
}
private DropshipCartonSku cartonSku(String skuCode, long quantity) {
DropshipCartonSku value = new DropshipCartonSku();
value.setSkuCode(skuCode); value.setProductQuantity(quantity); return value;
}
}
@@ -10,6 +10,7 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.entity.LastlegOrder;
@@ -102,6 +103,20 @@ class CargoDiscardServiceTest {
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
}
@Test
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
CargoDiscardCandidateRow skuB = candidate("SKU-B", 20L);
when(inventory.candidates("100", "RK-1")).thenReturn(List.of(skuA, skuB));
when(inventory.countAvailableOrder("100", 101L)).thenReturn(20L);
var result = service.candidates("100", "RK-1");
assertEquals(1, result.size());
assertEquals(20, result.get(0).availablePackages());
assertEquals(2, result.get(0).skus().size());
}
@Test
void approvalPushesToPdaWithoutDeductingInventory() {
CargoDiscardApplication application = pendingApplication();
@@ -179,4 +194,12 @@ class CargoDiscardServiceTest {
value.setCustomerId("100"); value.setCartonId(cartonId); value.setCartonMark("C-" + cartonId);
return value;
}
private CargoDiscardCandidateRow candidate(String skuCode, long packages) {
CargoDiscardCandidateRow value = new CargoDiscardCandidateRow();
value.setOrderId(101L); value.setInboundCode("RK-1"); value.setInboundOrderNo("IN-1");
value.setOverseasWarehouseId("WH-1"); value.setOverseasWarehouseName("LA");
value.setSkuCode(skuCode); value.setProductName(skuCode); value.setAvailablePackages(packages);
return value;
}
}
@@ -5,8 +5,10 @@ import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
import com.baiyue.lastleg.dropship.entity.DropshipCartonSku;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipCartonSkuMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
@@ -32,6 +34,7 @@ class DropshipOrderServiceTest {
@Mock private DropshipOrderProfileMapper profiles;
@Mock private DropshipSkuInventoryMapper skuInventory;
@Mock private DropshipCartonMapper cartons;
@Mock private DropshipCartonSkuMapper cartonSkus;
@Mock private DropshipCartonLedgerMapper ledger;
@Mock private WarehouseResourceGateway warehouse;
@InjectMocks private DropshipOrderService service;
@@ -62,6 +65,7 @@ class DropshipOrderServiceTest {
verify(profiles, times(2)).insert(any(DropshipOrderProfile.class));
verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class));
verify(cartons, times(3)).insert(any(DropshipCarton.class));
verify(cartonSkus, times(3)).insert(any(DropshipCartonSku.class));
verify(ledger, times(3)).insert(any(DropshipCartonLedger.class));
ArgumentCaptor<DropshipOrderProfile> profilesCaptor = ArgumentCaptor.forClass(DropshipOrderProfile.class);
@@ -88,6 +92,33 @@ class DropshipOrderServiceTest {
.containsOnly(0L);
}
@Test
void multiSkuCartonsAreStoredOnceWithSeparateSkuFacts() {
LastlegOrder order = new LastlegOrder();
order.setId(10L); order.setCustomerId("C-1"); order.setOrderType("ONE_CLICK_DROPSHIP");
order.setOrderNo("LL-10"); order.setPackageCount(2); order.setProductQuantity(30);
when(orders.selectById(10L)).thenReturn(order);
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of());
DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-MIXED",
List.of(new DropshipRequests.Pallet("SOURCE-P-1", List.of(
new DropshipRequests.SkuPackages("SKU-A", "A", 2, 20,
List.of("BOX-1", "BOX-2"), java.util.Map.of("BOX-1", 10L, "BOX-2", 10L)),
new DropshipRequests.SkuPackages("SKU-B", "B", 2, 10,
List.of("BOX-1", "BOX-2"), java.util.Map.of("BOX-1", 5L, "BOX-2", 5L))),
1, 2, 1, "2".repeat(64))));
assertThat(service.receive(request)).isEqualTo(2);
ArgumentCaptor<DropshipCarton> physical = ArgumentCaptor.forClass(DropshipCarton.class);
verify(cartons, times(2)).insert(physical.capture());
assertThat(physical.getAllValues()).extracting(DropshipCarton::getCartonMark)
.containsExactly("BOX-1", "BOX-2");
assertThat(physical.getAllValues()).extracting(DropshipCarton::getSkuCode).containsOnly("MIXED");
assertThat(physical.getAllValues()).extracting(DropshipCarton::getProductQuantity).containsOnly(15L);
verify(cartonSkus, times(4)).insert(any(DropshipCartonSku.class));
verify(ledger, times(2)).insert(any(DropshipCartonLedger.class));
}
@Test
void activatesMultiplePalletsOnlyAfterPdaStockIn() {
DropshipOrderProfile first = profile(1L, "SOURCE-P-1");
@@ -245,6 +245,29 @@ class InboundStockReceiptServiceTest {
assertEquals("WH-PALLET-21", activationReceipts.getValue().get(0).getPalletNo());
}
@Test
void multiSkuDropshipPalletStocksPhysicalCartonsOnce() {
Fixture fixture = fixture(5, 5, 5, 10);
fixture.order.setOrderType("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(
new DropshipPalletSnapshot("PLT-21", 5, List.of(
new DropshipPalletSnapshot.Sku("SKU-A", "A", 5, 50),
new DropshipPalletSnapshot.Sku("SKU-B", "B", 5, 25)))));
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
ArgumentCaptor<InventoryCommandRequests.StockIn> request =
ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class);
verify(inventory, times(1)).stockIn(eq("100"), eq("operator"), request.capture());
assertEquals("MIXED", request.getValue().skuCode());
assertEquals(5, request.getValue().packages());
verify(receipts, times(1)).insert(any(InboundStockReceipt.class));
}
@Test
void rejectsDomesticSourceWarehouseAsActualOverseasWarehouse() {
Fixture fixture = fixture(5, 5, 5, 10);
@@ -37,8 +37,8 @@ class SchemaChineseCommentContractTest {
String overseasUnified = Files.readString(
Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
List<TableDefinition> baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55);
assertAllChineseComments(incremental, "增量 SQL", 49);
List<TableDefinition> baselineTables = assertAllChineseComments(baseline, "基线 SQL", 56);
assertAllChineseComments(incremental, "增量 SQL", 50);
assertAllChineseComments(overseasUnified, "海外统一增量 SQL", 10);
assertCommentMigrationCovers(incremental, overseasUnified, baselineTables);
}
@@ -35,22 +35,23 @@ class LoadingCompletedConsumerTest {
}
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",2,10,1,BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,BigDecimal.ZERO,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-A1","BOX-A2"));
var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")),
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3"))));
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",2,10,List.of("BOX-A1","BOX-A2"))));
var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",2,30,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA");
EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","MSCU6639870","CAB-MULTI","92","Warehouse",List.of(order)),2);
assertEquals(1,service.consume(event));
verify(orders,times(1)).insert(any(LastlegOrder.class));
ArgumentCaptor<LastlegOrderDetail> detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class);
verify(details,times(2)).insert(detailCaptor.capture());
assertEquals(Map.of("SKU-A",2,"SKU-B",3),detailCaptor.getAllValues().stream().collect(java.util.stream.Collectors.toMap(LastlegOrderDetail::getSkuCode,LastlegOrderDetail::getPackageCount)));
assertEquals(Map.of("SKU-A",2,"SKU-B",2),detailCaptor.getAllValues().stream().collect(java.util.stream.Collectors.toMap(LastlegOrderDetail::getSkuCode,LastlegOrderDetail::getPackageCount)));
ArgumentCaptor<DropshipRequests.Receive> receiveCaptor=ArgumentCaptor.forClass(DropshipRequests.Receive.class);
verify(dropshipOrders).receive(receiveCaptor.capture());
assertEquals(2,receiveCaptor.getValue().pallets().get(0).skuPackages().size());
assertEquals(Map.of("SKU-A",2L,"SKU-B",3L),receiveCaptor.getValue().pallets().get(0).skuPackages().stream().collect(java.util.stream.Collectors.toMap(DropshipRequests.SkuPackages::skuCode,DropshipRequests.SkuPackages::packageCount)));
assertEquals(2,receiveCaptor.getValue().pallets().get(0).manifestPackageCount());
assertEquals(Map.of("SKU-A",2L,"SKU-B",2L),receiveCaptor.getValue().pallets().get(0).skuPackages().stream().collect(java.util.stream.Collectors.toMap(DropshipRequests.SkuPackages::skuCode,DropshipRequests.SkuPackages::packageCount)));
}
@Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
EventEnvelope source=event();
+11
View File
@@ -1284,6 +1284,17 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton (
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark), KEY idx_ll_dropship_carton_status(customer_id,status),
KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status)
) ENGINE=InnoDB COMMENT='一件代发箱唛明细';
CREATE TABLE IF NOT EXISTS ll_dropship_carton_sku (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID',
profile_id BIGINT NOT NULL COMMENT '一件代发托盘档案ID', carton_id BIGINT NOT NULL COMMENT '一件代发物理箱ID',
sku_code VARCHAR(128) NOT NULL COMMENT '箱内SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称快照',
product_quantity BIGINT NOT NULL COMMENT '该物理箱内此SKU实收件数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton_sku(carton_id,sku_code),
KEY idx_ll_dropship_carton_sku_order(customer_id,order_id,sku_code),
KEY idx_ll_dropship_carton_sku_profile(customer_id,profile_id,sku_code)
) ENGINE=InnoDB COMMENT='一件代发物理箱内SKU明细';
CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT NULL COMMENT '产品数量',