diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 17af0bea5..41a988862 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -28,8 +28,9 @@ public class SmartDispatchController { @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId, @RequestParam(required=false)List planIds,@RequestParam(required=false)String orderNo, - @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) { - return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken))); + @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken, + @RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) { + return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken,page,size,warehouseTab))); } /** 只列出当前拥有者未失效草稿。 */ @GetMapping("/drafts") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index b566fec62..06e0e54a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -9,6 +9,21 @@ import java.util.List; @Mapper public interface DispatchCandidateMapper { + /** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */ + String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))"; + /** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */ + String DEPARTED_PLAN = + "EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + + "ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" + + "SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + + "ON dl.id=da.line_id LEFT JOIN ll_order departure_order " + + "ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " + + "(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" + + "CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" + + "SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " + + "AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" + + "SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " + + "AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))"; // Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan. String CONFIRMED_APPOINTMENT_AT = "(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " + @@ -41,7 +56,7 @@ public interface DispatchCandidateMapper { List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); String AVAILABLE_PLAN_BODY = - "SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," + + "SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," + "MIN(l.order_id) AS orderId," + "MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," + "MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," + @@ -53,16 +68,16 @@ public interface DispatchCandidateMapper { "MIN(ip.container_no) AS containerNo," + CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," + // 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。 - "CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + "AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," + "(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " + "WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + "ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + @@ -70,11 +85,11 @@ public interface DispatchCandidateMapper { "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + "COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " + "ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," + - "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + + "CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + "(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " + "FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" + "SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " + @@ -108,12 +123,13 @@ public interface DispatchCandidateMapper { " AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')) " + " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + - "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + + "CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + + "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + "WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " + @@ -150,7 +166,7 @@ public interface DispatchCandidateMapper { @Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken); String SELECTED_PLAN_SQL = "";} + /** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */ + public static String page(){return "";} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java index 0ee629c41..9e961f9f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java @@ -12,7 +12,8 @@ public final class DispatchPlanningModels { private DispatchPlanningModels() { } public record CandidateQuery(String warehouseId, List planIds, String orderNo, - String destinationWarehouseCode, String sourceStage, String draftToken) { } + String destinationWarehouseCode, String sourceStage, String draftToken, + int page, int size, String warehouseTab) { } /** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */ public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion, @@ -36,7 +37,9 @@ public final class DispatchPlanningModels { public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List sourceKeys, long packageCount) { } - public record CandidateList(List candidates, Instant snapshotAt, boolean complete) { } + /** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */ + public record CandidateList(List candidates, Instant snapshotAt, boolean complete, + long total, int page, int size, long pages) { } public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity, List sourceKeys) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 0e90dcd0b..3009cb008 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -11,6 +11,13 @@ import java.util.List; /** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */ @Repository public class DispatchSourceRepository { + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper pages; + /** 筛选全集的数据库行数,与分页使用同一只读事务。 */ + public long candidateCount(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){return pages.count(query);} + /** 只返回当前页来源,禁止先全量装配再截取。 */ + public List candidatePage(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){ + return pages.page(query,Math.multiplyExact((long)query.page()-1,query.size())); + } private final DispatchCandidateMapper plans; private final ReturnDispatchCandidateMapper returns; private final DispatchSourceMapper source; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 556ef8c14..1601804d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -148,10 +148,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA requirePlatform(actor); if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓"); if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); + if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法"); var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), - trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); + trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()),query.page(),query.size(),query.warehouseTab()); long started=System.nanoTime(); - var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + var page=sourceRules.candidatePage(actor.operatorId(),normalized); + var rows=page.rows(); long candidatesRead=System.nanoTime(); var groups=sourceRules.selectionGroups(rows); long groupsRead=System.nanoTime(); @@ -192,7 +194,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA "dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}", rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L, (batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L); - return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); + return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS), + normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(), + (page.total()+normalized.size()-1)/normalized.size()); } /** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index add8a5eae..22ee82d6e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -16,6 +16,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; @Service public class DispatchPdaQueryService { @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources; + @jakarta.annotation.Resource private DispatchService sourceRules; private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} @@ -28,7 +30,7 @@ public class DispatchPdaQueryService { require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); } - /** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */ + /** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */ private List assemble(Actor actor,List roots) { if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号"); var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId)); @@ -59,6 +61,8 @@ public class DispatchPdaQueryService { var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); + if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null) + sourcePositions=receivedPlanPositions(actor,item,items); if(normalRelabel(item)) { var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList(); if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false; @@ -89,6 +93,39 @@ public class DispatchPdaQueryService { } return List.copyOf(result); } + /** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */ + private List receivedPlanPositions(Actor actor,DispatchItem item,List items) { + if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of(); + var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(rows.size()!=1)return List.of(); + var actual=rows.get(0); + if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null + || !Objects.equals(actual.planPalletId(),item.getPlanPalletId()) + || !Objects.equals(actual.customerId(),item.getCustomerId()) + || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) + || !Objects.equals(actual.packageCount(),item.getPackageCount()) + || !Objects.equals(actual.destinationType(),item.getDestinationType()) + || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) + || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of(); + var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); + if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll( + sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of(); + // 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。 + var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection( + SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); + if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of(); + for(var member:group.sources()) { + var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); + if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()) + || n(selected.getPackageCount())!=member.packages())return List.of(); + } + var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); + if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId()) + || !Objects.equals(p.palletId(),actual.warehousePalletId())) + || stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of(); + return stock.stream().filter(p->p.availablePackages()>0) + .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList(); + } /** 仓和操作者均来自受信身份,不接收请求自报。 */ private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} /** 缺失计数不作为业务标识使用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index ff2f8b705..09aa03622 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -212,32 +212,22 @@ public class DispatchService { public record WholeSource(DispatchPalletCandidate candidate, com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { } - /** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */ - public List wholeCandidates(String operator, - com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) { + /** 总数与当前页同事务读取;页外候选不参与逐组装配。 */ + public record CandidatePage(List rows,long total) { } + + /** 草稿所有权先验证,数据库分页后才读取测量和原生退货物理身份。 */ + public CandidatePage candidatePage(String operator,CandidateQuery query) { validateSourceStage(query.sourceStage()); if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream() .noneMatch(d -> query.draftToken().equals(d.getDraftToken()))) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源"); - var result=new java.util.ArrayList(); - if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query)); - if(query.planIds()==null || query.planIds().isEmpty()) { - if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query)); - if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) { - var rows=wholeSources.availableRelabel(query); - if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); - result.addAll(measuredRelabelCandidates(rows)); - result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo())); - } - } - if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); - return result.stream().filter(row -> query.destinationWarehouseCode()==null - || query.destinationWarehouseCode().equals(row.fbaWarehouseCode())) - .sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),"")) - .thenComparing(row -> Objects.toString(row.orderNo(),"")) - .thenComparing(row -> Objects.toString(row.palletNo(),""))).toList(); + long total=wholeSources.candidateCount(query); + var rows=wholeSources.candidatePage(query); + measuredRelabelCandidates(rows.stream().filter(row->"RELABEL".equals(row.sourceStage()) && !row.returnCartonSource()).toList()); + var nativeRows=rows.stream().filter(DispatchPalletCandidate::returnCartonSource).toList(); + if(!nativeRows.isEmpty())returnRelabelStock.resolvePage(nativeRows); + return new CandidatePage(List.copyOf(rows),total); } - /** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public List lockWholeSources(String warehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java index 2b7517eab..3813a7cb1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java @@ -27,11 +27,9 @@ public class ReturnRelabelDispatchStock { return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo)); } - /** 整车候选只解析已经按仓限定且有界的原生退货记录。 */ - public List wholeCandidates(String warehouse,String orderNo) { - var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null); - if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围"); - return resolve(rows); + /** 分页后仅核实本页原生退货实托;身份失效明确拒绝,不能静默缩页并返回错误总数。 */ + public void resolvePage(List rows) { + if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查"); } /** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 3a43629fe..1dd048ce0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper { record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } - /** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */ + /** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */ @Select(""" """) @@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper { List transportRows(@Param("batches") List batches,@Param("trips") List trips); @Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List dispatchPhysical(String number); @Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List dispatchPhysicalById(long id); - @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") + @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") List candidates(long id); @Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code") List physical(@Param("pallet") long pallet,@Param("customer") String customer); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 86c396041..518c5080a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -888,8 +888,8 @@ paths: '422': { description: 排车单没有可打印的 BOL 明细 } /api/platform/v1/last-leg/dispatches/candidates: get: - summary: 读取真实候选及来源版本 - description: 权限 platform:overseas-warehouse:dispatch:list。 + summary: 分页读取真实候选及来源版本 + description: 权限 platform:overseas-warehouse:dispatch:list。数据库按客户、订单、托盘和来源身份稳定排序分页;总数与当前页来自同一只读快照。selectionGroup仍包含完整同托及整申请来源,可包含页外成员;选取时服务端再次权威解析,不以当前页充当完整组。 security: - bearerAuth: [] responses: @@ -947,6 +947,26 @@ paths: in: query schema: type: string + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + - name: warehouseTab + in: query + description: 对起运仓名称和编码的服务端页签筛选,在统计和分页前生效。 + schema: + type: string + enum: [ALL, LAX, NYC] + default: ALL /api/platform/v1/last-leg/dispatches/drafts/pre-arrange: post: summary: 将所有非空容器原子转换为整车 @@ -6972,6 +6992,19 @@ components: nullable: true complete: type: boolean + description: 仅当前响应包含筛选后的全部候选时为true;分页中的false不影响每个selectionGroup的完整性。 + total: + type: string + pattern: ^[0-9]+$ + description: 同一筛选下的来源总行数。 + page: + type: integer + size: + type: integer + pages: + type: string + pattern: ^[0-9]+$ + required: [candidates, snapshotAt, complete, total, page, size, pages] DispatchDraftContainer: type: object x-java-record: DispatchPlanningModels.DraftContainer diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index daf2c0e44..5b0cdbba1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -16,12 +16,62 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { + /** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */ + @Test + void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { + try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { + var db = connection.createStatement(); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)"); + db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); + for (int id = 1; id <= 9; id++) { + db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')"); + db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); + db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); + db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); + db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")"); + if (id != 2) db.execute("INSERT INTO ll_container_shipping_gate VALUES (" + id + ",40,CURRENT_TIMESTAMP)"); + } + db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=5 WHERE id IN (3,4)"); + db.execute("INSERT INTO ll_inventory_position VALUES ('A',103,5),('A',104,0),('B',104,10)"); + db.execute("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=5"); + db.execute("UPDATE ll_order SET manual_node_override=TRUE WHERE id IN (6,7,9)"); + db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); + db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)"); + db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)"); + String plan = DispatchCandidateMapper.PLAN_ONLY; + String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) " + + "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))"; + var ids = new ArrayList(); + try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," + + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " + + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { + while (rows.next()) { + long id = rows.getLong("id"); ids.add(id); + assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage")); + assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class)); + } + } + assertEquals(List.of(1L, 3L, 7L), ids); + // 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。 + for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { + assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id"))); + assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); + } + } + } /** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */ @Test void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception { String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class, String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value()); - int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)"); + int start = sql.indexOf("CAST(CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id)"); int end = sql.indexOf(" AS overseasWarehouseId", start); assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影"); String expression = sql.substring(start, end).replace(">", ">").replace("<", "<"); @@ -35,15 +85,16 @@ class DispatchCandidateMapperContractTest { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) { var statement = connection.createStatement(); statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)"); - statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)"); + statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)"); statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))"); statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))"); statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL)," + "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL)," + "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21)," + "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)"); - statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + statement.execute("INSERT INTO ll_inbound_plan_pallet(id,plan_id,warehouse_pallet_id) VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)"); + statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (10,1,1000,'RESERVED',0)"); statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001')," + "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001')," + "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')"); @@ -51,7 +102,7 @@ class DispatchCandidateMapperContractTest { + "(20,'B','193000000000100001'),(21,'B','193000000000100001')"); try (var prepared = connection.prepareStatement(query)) { assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L)); - assertWarehouseRows(prepared, "193000000000200002", List.of(8L)); + assertWarehouseRows(prepared, "193000000000200002", List.of(8L, 10L)); for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong")) assertWarehouseRows(prepared, invalid, List.of()); } @@ -215,7 +266,7 @@ class DispatchCandidateMapperContractTest { Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class); String sql = String.join(" ", method.getAnnotation(Select.class).value()); - assertTrue(sql.contains("p.warehouse_pallet_id IS NULL OR EXISTS")); + assertTrue(sql.contains("AND (" + DispatchCandidateMapper.PLAN_ONLY + " OR EXISTS")); assertTrue(sql.contains("pos.available_packages > 0")); } @@ -249,7 +300,7 @@ class DispatchCandidateMapperContractTest { String.class, String.class, String.class), DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); - assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL")); + assertTrue(sql.contains("CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY)); assertTrue(sql.contains("pos.overseas_warehouse_id")); assertTrue(sql.contains(method.getName().equals("available") ? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 33680f23e..1d1e13557 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -38,7 +38,9 @@ class SmartDispatchOpenApiContractTest { assertThat(map(paths.get(base+"/drafts"))).containsKey("get"); var candidate=map(map(paths.get(base+"/candidates")).get("get")); var parameterNames=((List)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList(); - assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode"); + assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode"); + assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet()) + .containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages"); var draft=map(map(schemas.get("DispatchDraft")).get("properties")); assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt"); var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index be757d18e..80849d595 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -33,8 +33,8 @@ class SmartDispatchControllerTest { /** 候选仍委托实际主流程,保留仓库及全部真实筛选。 */ @Test void candidatesKeepWarehouseMultiCustomerFilters(){ - controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1"); - verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1")); + controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX"); + verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX")); verifyNoInteractions(planning); } /** HTTP实际参数采用destinationWarehouseCode,不能因旧名称使前端筛选静默丢失。 */ @@ -43,7 +43,7 @@ class SmartDispatchControllerTest { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of())); mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseId","92").param("destinationWarehouseCode","LAX9")) .andExpect(status().isOk()); - verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null));verifyNoInteractions(planning); + verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning); } /** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */ @Test void allDraftRoutesDelegateFrozenVersionedRequests(){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index e000a06ac..185a1bf09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -28,6 +28,45 @@ import static org.mockito.Mockito.*; /** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */ class DispatchMergedRelabelPersistenceTest { + /** 原生退货分页按实箱去重计量,不能因申请多明细重复计箱或重量。 */ + @Test void nativeReturnPageMeasuresOneCartonOnce() throws Exception { + var f=new Merged(false);var at=LocalDateTime.of(2026,1,1,0,0); + f.insert("ll_return_inventory_carton","id",901,"customer_id","101","overseas_warehouse_id","9", + "carton_mark","NATIVE-CARTON","pallet_code","NATIVE-PALLET","relabel_application_id",201, + "relabel_no","RL-A","status","RELABEL_PENDING","sku_types",1,"total_quantity",2, + "weight",12,"length",100,"width",100,"height",100,"created_at",at,"updated_at",at); + f.f.db.update("UPDATE ll_relabel_item SET source_inventory_position_id=NULL,return_carton_id=901 WHERE id=301"); + f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","同箱另一明细", + "total_package_count",1,"notified_package_count",1,"carton_mark","NATIVE-CARTON","operation_type","CARTON_LABEL", + "return_carton_id",901,"destination_fba_warehouse_code","ONT8"); + var query=new DispatchPlanningModels.CandidateQuery("9",null,"RL-A",null,"RELABEL",null,1,20,"ALL"); + var mapper=f.f.session.getMapper(DispatchCandidatePageMapper.class); + assertThat(mapper.count(query)).isOne(); + var row=mapper.page(query,0).get(0); + assertThat(row.returnCartonSource()).isTrue();assertThat(row.packageCount()).isOne(); + assertThat(row.grossWeightKg()).isEqualByComparingTo("12");assertThat(row.volumeCbm()).isEqualByComparingTo("1"); + f.f.db.update("UPDATE ll_return_inventory_carton SET weight=NULL,height=NULL WHERE id=901"); + row=mapper.page(query,0).get(0); + assertThat(row.grossWeightKg()).isNull();assertThat(row.volumeCbm()).isNull(); + } + /** 数据库限页不能截断同托来源组;总数、翻页及服务端搜索必须一致。 */ + @Test void candidatePagesRetainCompleteCrossPageSelectionGroup() throws Exception { + var f=new Merged(false); + var all=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,null,null,1,1,"ALL")); + assertThat(all.total()).isEqualTo(2);assertThat(all.rows()).hasSize(1); + var first=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,1,"ALL")); + var second=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,2,1,"ALL")); + assertThat(first.total()).isEqualTo(2);assertThat(second.total()).isEqualTo(2); + assertThat(first.rows()).hasSize(1);assertThat(second.rows()).hasSize(1); + assertThat(first.rows().get(0).orderRefs()).isNotEqualTo(second.rows().get(0).orderRefs()); + var group=f.rules.selectionGroups(first.rows()).values().iterator().next(); + assertThat(group.complete()).as(group.blockReasons().toString()).isTrue(); + assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo).containsExactlyInAnyOrder("RL-A","RL-B"); + var filtered=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,"RL-B","ONT8","RELABEL",null,1,1,"ALL")); + assertThat(filtered.total()).isOne();assertThat(filtered.rows()).extracting(r->r.orderRefs()).containsExactly("RL-B"); + var empty=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("99",null,null,null,"RELABEL",null,1,1,"ALL")); + assertThat(empty.total()).isZero();assertThat(empty.rows()).isEmpty(); + } /** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */ @ParameterizedTest @ValueSource(booleans={false,true}) void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception { @@ -161,7 +200,7 @@ class DispatchMergedRelabelPersistenceTest { "source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING", "destination_fba_warehouse_code","ONT8"); f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED"); - var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null)); + var candidates=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,20,"ALL")).rows(); var groups=f.rules.selectionGroups(candidates); assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L)); var group=groups.get("RELABEL:RL-A:501"); @@ -190,17 +229,22 @@ class DispatchMergedRelabelPersistenceTest { for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", - "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { + "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate", + "ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) { f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table); // H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。 f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","") + .replace("_utf8mb4'","'") + .replace("_utf8mb4", "") .replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2") .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); } var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json); var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json); + f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class); var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class)); ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class)))); ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java new file mode 100644 index 000000000..12932f0d1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */ +class DispatchPlanPdaReadPersistenceTest { + /** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */ + @Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception { + var f=new Fixture();var before=f.dbState(); + var task=f.read(); + assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L); + assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3); + assertThat(f.dbState()).isEqualTo(before); + verify(f.sources).stock("101",501L,false); + verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any()); + verify(f.rules,never()).lockExecutionSources(anyString(),anyList()); + } + /** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */ + @Test void unreceivedOrChangedPlanHasNoPositions() throws Exception { + var f=new Fixture();var row=f.f.candidates.get(1L); + row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty(); + row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty(); + row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); + row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty(); + verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); + } + /** 原计划不能吸收新的订单或未经原车审核的同托来源。 */ + @Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception { + var f=new Fixture(); + when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L)); + assertThat(f.read().items().get(0).positions()).isEmpty(); + when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); + when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( + "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of())); + assertThat(f.read().items().get(0).positions()).isEmpty(); + verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); + } + /** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */ + @Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception { + var f=new Fixture(); + for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1), + new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) { + when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock)); + assertThat(f.read().items().get(0).positions()).isEmpty(); + } + } + /** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */ + private static final class Fixture { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchSourceRepository sources=mock(DispatchSourceRepository.class); + final DispatchService rules=mock(DispatchService.class); + final DispatchPdaQueryService queries;final String number; + /** 原车已审核,只清除自然收货尚未补写的实托字段。 */ + Fixture() throws Exception { + var selection=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection)))); + f.approve(created);number=created.dispatchNo(); + f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'"); + f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet(); + queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); + ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules); + when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L))); + when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L)); + when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); + when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1))); + when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( + "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of())); + } + /** 读取正式PDA入口。 */ + DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));} + /** 比较持久化原明细与主单版本。 */ + Object dbState(){return List.of(f.db.queryForList("SELECT * FROM ll_dispatch_item"),f.db.queryForList("SELECT record_version FROM ll_dispatch_order"));} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 10478e7c8..9962d2043 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -58,12 +58,16 @@ class DispatchServiceTest { verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial); } @Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){ - assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources); + assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources); } - @Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){ - var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null); - when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101"))); - assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102"); + @Test void candidatePagePreservesDatabaseOrderAndDoesNotReadOtherRows(){ + var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null,2,20,"ALL"); + when(sources.candidateCount(query)).thenReturn(55L); + when(sources.candidatePage(query)).thenReturn(List.of(row(1,501,"101"),row(2,502,"102"))); + var page=service.candidatePage("operator",query); + assertThat(page.total()).isEqualTo(55); + assertThat(page.rows()).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102"); + verify(sources).candidateCount(query);verify(sources).candidatePage(query);verifyNoMoreInteractions(sources); } @Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){ var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java index d22b4b544..0d500f4a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -26,6 +26,23 @@ import static org.mockito.Mockito.*; /** 真实MyBatis、批次行锁、逐客户分配与回滚;原批次和来源读取边界由当前DB事实供给。 */ class AppointmentWorkDispatchPersistenceTest { + /** 自动预留托盘与已实收托盘分开取数,防止有预约的在途货物被零实收数量挡住。 */ + @Test void reservedPalletKeepsPlannedQuantityInBatchReferencesUntilReceivingStarts() { + jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='RESERVED',actual_package_count=0 WHERE id=111"); + jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0 WHERE pallet_id=111"); + var physical = List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",111L)); + assertEquals("PLAN", mapper.candidateSources(physical).get(0).sourceStage()); + assertEquals("PLAN", mapper.candidates(1L).stream().filter(row -> row.planPalletId()==111).findFirst().orElseThrow().sourceStage()); + var planned = candidates.batchReferences(physical).get(physical.get(0)); + assertTrue(planned.blockReasons().isEmpty()); + assertEquals(80, planned.appointmentBatches().get(0).sources().get(0).packageCount()); + jdbc.update("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=111"); + assertEquals("RECEIVED", mapper.candidateSources(physical).get(0).sourceStage()); + assertFalse(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty()); + jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=80 WHERE id=111"); + jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=80 WHERE pallet_id=111"); + assertTrue(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty()); + } private JdbcTemplate jdbc; private TransactionTemplate tx; private AppointmentWorkDispatchPort binding; @@ -46,7 +63,7 @@ class AppointmentWorkDispatchPersistenceTest { jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))"); jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)"); jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(40))"); - jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)"); jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)"); jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),dispatch_order_id BIGINT,expires_at TIMESTAMP)"); @@ -67,7 +84,7 @@ class AppointmentWorkDispatchPersistenceTest { /** 插入真实客户、托盘和逐票数量,不从请求猜测。 */ private void seed(long item,String customer,long order,int count,String destination) { jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count); - jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); + jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count); }