diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index e55c3d8e3..6e50d87f2 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -588,6 +588,23 @@ CREATE TABLE ll_dropship_carton ( KEY idx_ll_dropship_carton_sku (customer_id,sku_code,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发箱唛明细'; +CREATE TABLE ll_dropship_carton_sku ( + id bigint NOT NULL COMMENT '主键ID', + customer_id varchar(128) COLLATE utf8mb4_unicode_ci 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) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱内SKU编码', + product_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT 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 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发物理箱内SKU明细'; + CREATE TABLE ll_dropship_carton_ledger ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', @@ -2978,4 +2995,4 @@ USE baiyue_lastleg; INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 136 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 137 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index d969bb2d9..c07eb3662 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -21,18 +21,44 @@ public interface LoadingModelBridgeMapper extends BaseMapper0") + @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 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 @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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index 7ab029bc1..e3f64662c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -88,16 +88,23 @@ public interface LoadingPickTaskMapper extends BaseMapper 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} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index a0d076627..4832d048a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -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> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( PalletRow::getPalletCode, LinkedHashMap::new, java.util.stream.Collectors.toList())); List result = new ArrayList<>(); - int actualPackages = 0; + Set actualPackages = new HashSet<>(); for (Map.Entry> pallet : byPallet.entrySet()) { Map> 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 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()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 5908024e2..e4bf7b15b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -961,19 +961,7 @@ public class LoadingPickService { .collect(Collectors.groupingBy(CartonLabelRow::getPickItemId)); Map tenantByInbound = tenantByInbound(task.getId()); Map> inventoryByInbound = inventoryByInbound(source, tenantByInbound); - List items = source.stream().map(item -> { - List 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 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 pickItems(List source, + Map> cartonsByItem, + Map tenantByInbound, + Map> inventoryByInbound) { + Map> 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 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 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 result = new ArrayList<>(); + for (List members : units.values()) { + LoadingPickItemEntity firstItem = members.get(0); + List 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 boxCodes = cartons.stream().map(CartonLabelRow::getBoxCode) + .filter(StringUtils::hasText).distinct().toList(); + List 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 validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) { long taskId = task.getId(); List allItems = repository.items(taskId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java index 4e410fb49..fb8e91cd4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/PdaPickSettlementService.java @@ -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 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 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 orderQuantities = dropshipWholePallet - ? new LinkedHashMap<>(pickedByDetail) : measurementQuantities(measurementLines); + Map 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 pickedByInbound(List 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 pickedByInbound(List taskItems) { + Map result = new HashMap<>(); + Set 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> 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 physical = + dropshipPallets.palletInboundMembersByPalletIds(pickedPalletIds); + Set 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 pickedByDetail(List taskItems, long inboundId) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapperContractTest.java index 53864fc75..b5968057b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapperContractTest.java @@ -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"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 86603b740..139c3101e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -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 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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 326d6b4ed..d6aef5511 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -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(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java index 9d6bbb093..f3fb214c7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/PdaPickSettlementServiceTest.java @@ -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 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"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 97ad7720d..45b3fe641 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -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 requestedPackages = new LinkedHashMap<>(); Map candidates = new HashMap<>(); + Set 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 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 cartons = dropshipCartons.lockAvailable(customerId, - candidate.getProfileId(), sku, requested.getValue()); + List 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 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 shippedSkus = new LinkedHashMap<>(); for (DropshipCarton carton : cartons) { if (carton.getProductQuantity() == null || carton.getProductQuantity() <= 0) throw conflict("出库箱唛缺少逐箱实收件数"); - productQuantity = Math.addExact(productQuantity, carton.getProductQuantity()); + List 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 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 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 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 items) { List palletIds = items.stream().map(DispatchItem::getWarehousePalletId) .filter(Objects::nonNull).distinct().toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java index 93a8178cc..6b642d1da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -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' " + "AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') " + "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 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 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java index bcad34598..51f106d68 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -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 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 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 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(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 6b1957326..969cf160a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -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 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 { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonSku.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonSku.java new file mode 100644 index 000000000..58b581862 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonSku.java @@ -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; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java index 624db797d..9e58d1a5d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -27,6 +27,10 @@ public interface DropshipCartonMapper extends BaseMapper { @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 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 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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonSkuMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonSkuMapper.java new file mode 100644 index 000000000..9c20f6039 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonSkuMapper.java @@ -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 { + @Select("SELECT * FROM ll_dropship_carton_sku WHERE customer_id=#{customerId} AND carton_id=#{cartonId} ORDER BY id") + List listCarton(@Param("customerId") String customerId, + @Param("cartonId") Long cartonId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java index 1653db542..6f434b5d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -10,21 +10,24 @@ import java.util.List; public interface DropshipOrderProfileMapper extends BaseMapper { 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 listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @@ -50,12 +53,15 @@ public interface DropshipOrderProfileMapper extends BaseMapper" + OUTBOUND_CANDIDATE_SELECT + OUTBOUND_CANDIDATE_WHERE + "AND pos.overseas_warehouse_id=#{warehouseId} " + - "ORDER BY b.overseas_warehouse_name,p.pallet_no,s.sku_code LIMIT 500") + "ORDER BY b.overseas_warehouse_name,p.pallet_no,sku_code LIMIT 500") List 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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java index 81d83bb37..d3d30e429 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -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> 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> entry : factsByMark.entrySet()) { + List 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 marks = new HashSet<>(); + Map palletByMark = new java.util.HashMap<>(); Set 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 skus = new HashSet<>(); + Set 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 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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 3888863a8..9a080980d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -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)) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 5a77ea048..15e448879 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -219,9 +219,22 @@ public class LoadingCompletedConsumer { FirstLegLoadingPlanCompletedV1.Order order = payload.orders().get(index); Set 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 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)); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index fdd1e69f5..e92e0168c 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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 '产品数量', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java index df43dfc50..4f7071583 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java @@ -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")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index e7e1e64ef..64226bf1e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -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; + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardPhysicalCartonMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardPhysicalCartonMapperContractTest.java new file mode 100644 index 000000000..f1be16f64 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardPhysicalCartonMapperContractTest.java @@ -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()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java index c3a86f51f..17061788e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java @@ -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; + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index cef52821c..fbe05b4c4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -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; + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java index 1c312387a..fac18ec1d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -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 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 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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 12aabff78..4775eff2a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -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 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index 3bd44326b..ad3f1905b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -37,8 +37,8 @@ class SchemaChineseCommentContractTest { String overseasUnified = Files.readString( Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); - List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55); - assertAllChineseComments(incremental, "增量 SQL", 49); + List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 56); + assertAllChineseComments(incremental, "增量 SQL", 50); assertAllChineseComments(overseasUnified, "海外统一增量 SQL", 10); assertCommentMigrationCovers(incremental, overseasUnified, baselineTables); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index e4efad347..4eb28750f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -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 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 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(); diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 0b05c65ec..f705f966d 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -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 '产品数量',