diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java index cef46bf6c..dc4710531 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java @@ -16,6 +16,17 @@ public interface AppointmentCargoVerificationMapper extends BaseMapper + SELECT ranked.* FROM ( + SELECT v.*,ROW_NUMBER() OVER(PARTITION BY customer_id,source_key ORDER BY version DESC) work_row + FROM ll_appointment_cargo_verification v WHERE + (customer_id=#{ref.customerId} AND source_key=#{ref.sourceKey}) + ) ranked WHERE work_row=1 + + """) + java.util.List latestBatch(@Param("refs") java.util.List refs); + @Select("SELECT * FROM ll_appointment_cargo_verification WHERE customer_id=#{customerId} AND source_key=#{sourceKey} AND command_key=#{key}") AppointmentCargoVerification command(@Param("customerId") String customerId,@Param("sourceKey") String sourceKey,@Param("key") String key); @@ -27,4 +38,15 @@ public interface AppointmentCargoVerificationMapper extends BaseMapper + SELECT customer_id,source_key,COALESCE(SUM(package_count),0) packages,COUNT(*)-COUNT(package_count) unknown_items + FROM ll_inbound_fba_appointment_item WHERE active=1 AND ( + (customer_id=#{ref.customerId} AND source_key=#{ref.sourceKey}) + ) GROUP BY customer_id,source_key + + """) + java.util.List allocatedBatch(@Param("refs") java.util.List refs); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java index b4c5605a1..e0bc99167 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -47,10 +47,36 @@ public class AppointmentCargoVerificationService { var source=sources.resolve(sourceKey,customerId,destinationKey); return inspect(source); } + public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { + public Context requireContext() { if(error!=null) throw error; return context; } + } + /** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ + public List inspectBatch(List refs) { + var result=new ArrayList(); + for(int start=0;startr.error()==null).map(AppointmentSourceService.Resolution::reference).toList(); + var latest=new HashMap,AppointmentCargoVerification>(); + var allocations=new HashMap,AppointmentCargoVerificationMapper.Allocation>(); + if(!valid.isEmpty()) { + mapper.latestBatch(valid).forEach(r->latest.put(List.of(r.getCustomerId(),r.getSourceKey()),r)); + mapper.allocatedBatch(valid).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems()))); + } + for(var resolution:resolved) { + var key=List.of(resolution.reference().customerId(),resolution.reference().sourceKey()); + result.add(new Inspection(resolution.reference(),resolution.error()==null?inspect(resolution.source(),latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))):null,resolution.error())); + } + } + return List.copyOf(result); + } /** Uses an already-authorized batched source resolution; never repeats warehouse HTTP lookups per row. */ public Context inspect(AppointmentSourceService.Source source) { var row=mapper.latest(source.customerId(),source.sourceKey()); var allocation=mapper.allocated(source.customerId(),source.sourceKey()); + return inspect(source,row,allocation); + } + private Context inspect(AppointmentSourceService.Source source,AppointmentCargoVerification row,AppointmentCargoVerificationMapper.Allocation allocation) { boolean changed=row!=null && changed(row,source); Integer total=row==null?source.packages():row.getPackageCount(); var reasons=new ArrayList<>(source.blockReasons()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java index 1aa501921..62757c22c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -60,12 +60,22 @@ public class AppointmentSourceService { } /** One catalog request and batched source reads, including mixed first-leg and external bookings. */ public List resolveBatch(List requested) { + return resolveBatch(requested,true).stream().map(Resolution::requireSource).toList(); + } + /** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */ + public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) { + public Source requireSource() { if(error!=null) throw error; return source; } + } + public List inspectBatch(List requested) { + return resolveBatch(requested,false); + } + private List resolveBatch(List requested,boolean strict) { if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源"); var keys=new LinkedHashSet(); var orderIds=new LinkedHashSet(); var bookingRefs=new java.util.ArrayList(); for(var ref:requested) { if(ref==null || blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源"); - if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择"); + if(!keys.add(ref.sourceKey()) && strict) throw bad("同一来源货物不能重复选择"); if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey())); else if(ref.sourceKey().startsWith("BOOKING:") && !blank(ref.sourceKey().substring(8))) bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8))); else throw bad("不支持的货源类型"); @@ -77,17 +87,28 @@ public class AppointmentSourceService { if(booking.orderId()!=null) orderIds.add(booking.orderId()); } var orderRows=orderIds.isEmpty()?List.of():orders.selectBatchIds(orderIds); - var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).toList(); - if(!directOrders.isEmpty() && !external.resolveOrders(directOrders).isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约"); + var directOrders=requested.stream().filter(ref->ref.sourceKey().startsWith("ORDER:")).map(ref->new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))).distinct().toList(); + var externalOrders=directOrders.isEmpty()?Set.of():external.resolveOrders(directOrders).stream() + .map(s->new AppointmentExternalSourceGateway.OrderRef(s.customerId(),s.orderId())).collect(Collectors.toSet()); + if(strict && !externalOrders.isEmpty()) throw bad("该货物来自外接登记,请使用原登记来源继续预约"); var orderMap=orderRows.stream().collect(Collectors.toMap(com.baiyue.lastleg.entity.LastlegOrder::getId,Function.identity())); var transferRefs=orderRows.stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList(); var destinations=transferRefs.isEmpty()?Map.of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity())); var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); var gates=containerIds.isEmpty()?Map.of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40)); var catalog=catalog(); - var result=requested.stream().map(ref->resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations)).toList(); + var result=requested.stream().map(ref->{ + try { + if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey())))) + throw bad("该货物来自外接登记,请使用原登记来源继续预约"); + return new Resolution(ref,resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,catalog,destinations),null); + } catch(ResponseStatusException error) { + if(strict || error.getStatusCode().value()>=500) throw error; + return new Resolution(ref,null,error); + } + }).toList(); var materialized=new java.util.HashSet(); - for(var source:result) if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); + if(strict) for(var resolution:result) { var source=resolution.requireSource(); if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); } return result; } private Source resolve(String sourceKey,String customerId,String destinationKey, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java index 0cbe0c8a4..c6e8fe981 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java @@ -29,6 +29,12 @@ public class AppointmentWorkCommitmentService { var row=mapper.commitment(key); return key+":"+(row==null?0:row.getImpactVersion()); } + public java.util.Map impactTokens(java.util.List keys) { + var tokens=new java.util.HashMap(); + keys.forEach(key->tokens.put(key,key+":0")); + if(!keys.isEmpty()) mapper.commitments(keys).forEach(row->tokens.put(row.getEntityKey(),row.getEntityKey()+":"+row.getImpactVersion())); + return tokens; + } public static boolean impacts(Commitment old,Commitment now) { if(old.cancelled()!=now.cancelled()) return true; boolean firstSameDayConfirmation=old.confirmed()==null && now.confirmed()!=null && old.target()!=null && old.target().date().equals(now.confirmed().date()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java index bf802c8f6..dca97dae5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java @@ -44,6 +44,8 @@ public interface AppointmentWorkMapper { } @Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key} FOR UPDATE") CommitmentRow lockCommitment(String key); @Select("SELECT * FROM ll_appointment_work_commitment WHERE entity_key=#{key}") CommitmentRow commitment(String key); + @Select("") + List commitments(@Param("keys") List keys); @Insert("INSERT INTO ll_appointment_work_commitment(entity_key,state_json,impact_version) VALUES(#{key},#{json},0)") int insertCommitment(@Param("key") String key,@Param("json") String json); @Update("UPDATE ll_appointment_work_commitment SET state_json=#{json},impact_version=#{version} WHERE entity_key=#{key}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java index 5623f1bd0..11c4dd4b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java @@ -7,6 +7,7 @@ import java.time.*; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; @@ -23,16 +24,29 @@ public class AppointmentWorkQueryService { AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts,Clock clock) { this.store=store; this.facts=facts; this.clock=clock; } record Snapshot(List states,Map facts,List items) { } Snapshot snapshot(Actor actor) { + return snapshot(actor,s->true,true); + } + private Snapshot snapshot(Actor actor,Predicate selected,boolean includeNext) { String scopeVersion=store.requireInitialized(); if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步"); var all=store.all(); - var states=all.stream().filter(s->!s.isRetired()).toList(); + var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); + var selectedKeys=all.stream().filter(s->!s.isRetired() && selected.test(s)).map(AppointmentWorkState::getWorkItemKey).collect(Collectors.toSet()); + var needed=new HashSet<>(selectedKeys); + if(includeNext) { + var pending=new ArrayDeque<>(selectedKeys); + while(!pending.isEmpty()) { + var state=stateByKey.get(pending.remove()); + for(var key:effectiveKeys(state.getPrerequisites(),stateByKey)) if(stateByKey.containsKey(key) && needed.add(key)) pending.add(key); + } + } + var states=all.stream().filter(s->!s.isRetired() && needed.contains(s.getWorkItemKey())).toList(); var current=facts.facts(states); var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList(); var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList(); var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity())); - var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); - return new Snapshot(all,current,items.stream().map(i->withNext(i,next(i,byKey,stateByKey,new HashSet<>()))).toList()); + return new Snapshot(all,current,items.stream().filter(i->selectedKeys.contains(i.workItemKey())) + .map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList()); } private Action next(WorkItem item,Map items,Map states,Set visited) { if(!visited.add(item.workItemKey()) || item.blockReasons().isEmpty() || item.reviewRequired()) return item.nextAction(); @@ -46,13 +60,13 @@ public class AppointmentWorkQueryService { return item.nextAction(); } public Summary summary(Actor actor) { - var items=snapshot(actor).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); + var items=snapshot(actor,s->true,false).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count()); } public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set types,Actor actor) { if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天"); if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看"); - var items=snapshot(actor).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) + var items=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) .filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList(); var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList())); var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList(); @@ -61,7 +75,7 @@ public class AppointmentWorkQueryService { public PageResult items(View view,int page,int size,LocalDate date,Flow flow,Set types,String containerId,Bucket bucket,String keyword,Actor actor) { if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效"); var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT); - var items=snapshot(actor).items().stream() + var items=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items().stream() .filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED) .filter(i->date==null || date.equals(i.calendarDate())) .filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type())) @@ -89,6 +103,21 @@ public class AppointmentWorkQueryService { requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff()); } WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); } + private boolean matches(AppointmentWorkState state,Flow flow,Set types,String containerId) { + return (flow==null || state.getFlow()==flow) && (types==null || types.contains(state.getType())) + && (containerId==null || (state.getContainers().isEmpty()?containerId.equals(state.getContainerBookingId()):state.getContainers().stream().anyMatch(c->containerId.equals(c.containerBookingId())))); + } + private boolean candidate(AppointmentWorkState state,View view) { + // Follow-up dates and handoffs belong to the work state. Use the latest possible local day, + // not its stored timezone: the authoritative fact can supply a different current zone. + // Exact local-day, completion and permission checks still run after the live fact read. + return switch(view) { + case FOLLOW_TODAY -> pendingHandoff(state) || state.getFollowupDate()!=null + && !state.getFollowupDate().isAfter(LocalDate.now(clock.withZone(ZoneOffset.MAX))); + case REVIEW -> state.isReviewRequired(); + default -> true; + }; + } private String searchable(WorkItem i) { return String.join(" ",Objects.toString(i.businessNo(),""),Objects.toString(i.batchNo(),""),Objects.toString(i.originName(),""),Objects.toString(i.destinationName(),""),i.containers().stream().map(c->Objects.toString(c.containerNo(),"")+" "+Objects.toString(c.bookingNo(),"")).collect(Collectors.joining(" "))).toLowerCase(Locale.ROOT); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index 227c0a3ef..1d6b91fa9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -145,6 +145,20 @@ public class InboundFbaAppointmentService { } return new FbaAppointmentBatchContext(snapshot,List.copyOf(allocations),List.copyOf(actions),reasons.stream().distinct().toList()); } + /** Original persisted appointment facts only; reading work status must not run split/merge eligibility. */ + @Transactional(readOnly=true) + public List batchSnapshots(List keys) { + if(keys==null || keys.isEmpty()) return List.of(); + if(keys.size()>200) throw bad("每次最多读取200个预约批次"); + var ids=keys.stream().map(key->{ + try {long id=Long.parseLong(key.substring("FBA_APPOINTMENT:".length()));if(id>0 && key.equals(batchKey(id))) return id;} + catch(RuntimeException ignored) { } + throw bad("批次关联键无效"); + }).distinct().toList(); + var rows=appointmentMapper.selectBatchIds(ids); + if(rows.size()!=ids.size()) throw missing(); + return responses(rows); + } @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public FbaAppointmentResponse update(String operatorId,String appointmentNo,UpdateFbaAppointmentRequest request) { if ("CANCELLED".equals(status(request.details().appointmentStatus()))) throw bad("取消预约请使用取消操作并填写实际取消原因"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java index 9ab6a99cd..8b4a7e76b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java @@ -16,4 +16,20 @@ public interface AppointmentWorkDeliveryMapper { record Delivery(String customerId,String sourceKey,String destinationKey,Integer allocatedPackages,String dispatchStatus,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt) { } @Select("SELECT b.customer_id,b.source_key,b.destination_key,b.package_count allocated_packages,d.status dispatch_status,x.delivery_no,x.status,x.package_count packages,x.signed_packages,x.signed_at FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id AND d.status<>'CANCELLED' LEFT JOIN ll_delivery_order x ON x.dispatch_order_id=b.dispatch_id AND x.customer_id=b.customer_id AND CONCAT('FBA:',x.destination_warehouse_code)=b.destination_key WHERE b.batch_key=#{batch} ORDER BY b.dispatch_item_id,b.source_key,x.id LIMIT 1001") List batch(String batch); + record BatchDelivery(String batchKey,String customerId,String sourceKey,String destinationKey,Integer allocatedPackages,String dispatchStatus,String deliveryNo,String status,Integer packages,Integer signedPackages,LocalDateTime signedAt) { + public Delivery delivery() { return new Delivery(customerId,sourceKey,destinationKey,allocatedPackages,dispatchStatus,deliveryNo,status,packages,signedPackages,signedAt); } + } + @Select(""" + + """) + List batches(@Param("batches") List batches); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java index 059c8f6e2..72f46cbe1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.work; import com.baiyue.lastleg.appointmentplanning.*; import com.baiyue.lastleg.appointmentwork.*; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.security.OverseasPermissions; import org.springframework.stereotype.Component; import java.util.*; @@ -22,14 +23,43 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping; } public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; } - public List read(List requests) { return requests.stream().map(this::read).toList(); } - private Fact read(FactRequest request) { - if(request.batchKey()==null) return unbatched(request); + public List read(List requests) { + var mapped=new HashMap>(); + var privateSources=new HashMap>(); + var containers=requests.stream().filter(r->r.batchKey()==null && r.requirementKey()!=null && r.requirementKey().startsWith("INBOUND:")).map(FactRequest::containerBookingId).distinct().toList(); + if(!containers.isEmpty()) sourceMapping.containerSourcesBatch(containers).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source())); + for(var request:requests) if(request.batchKey()==null && request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) { + var sources=mapped.getOrDefault(request.containerBookingId(),List.of()); + if(sources.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); + var related=sources.stream().filter(s->request.requirementKey().equals("INBOUND:"+s.sourceInboundId())).toList(); + if(!related.isEmpty() && related.stream().allMatch(s->"PRIVATE".equals(s.destinationType()))) privateSources.put(request.workItemKey(),related); + } + var refs=requests.stream().filter(r->r.batchKey()==null && !privateSources.containsKey(r.workItemKey())).flatMap(r->r.sources().stream()) + .map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); + var inspections=new HashMap(); + if(!refs.isEmpty()) cargo.inspectBatch(refs).forEach(i->inspections.put(i.reference(),i)); + var batches=requests.stream().map(FactRequest::batchKey).filter(k->k!=null && !k.startsWith("DISPATCH:")).distinct().toList(); + var snapshots=new HashMap(); + var deliveryRows=new HashMap>(); + Map impacts=Map.of(); + if(!batches.isEmpty()) { + appointments.batchSnapshots(batches).forEach(a->snapshots.put(a.batchKey(),a)); + deliveries.batches(batches).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery())); + impacts=commitments.impactTokens(batches); + } + var context=new BatchFacts(snapshots,deliveryRows,impacts); + return requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList(); + } + private record BatchFacts(Map appointments,Map> deliveries,Map impacts) { } + private Fact read(FactRequest request,List privateSources,Map inspections,BatchFacts context) { + if(request.batchKey()==null) return privateSources!=null?customer(request,privateSources):unbatched(request,inspections); if(request.batchKey().startsWith("DISPATCH:")) return customerBatch(request); - var context=appointments.batchContext(request.batchKey()); var a=context.appointment(); + var a=context.appointments().get(request.batchKey()); + var impact=context.impacts().get(request.batchKey()); + if(a==null || impact==null) throw unavailable("预约批次事实批量读取不完整"); boolean cancelled=Set.of("CANCELLED","FAILED").contains(a.appointmentStatus()); var target=cancelled?null:target(a); var confirmed=cancelled?null:confirmed(a); - var rows=deliveries.batch(a.batchKey()); + var rows=context.deliveries().getOrDefault(a.batchKey(),List.of()); if(rows.size()>1000) throw unavailable("批次派送事实超过完整读取上限"); boolean complete=!rows.isEmpty() && rows.stream().allMatch(d->d.status()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(d.status()) && d.signedAt()!=null && d.packages()!=null && d.packages().equals(d.signedPackages())); // Every original source allocation must have real dispatch coverage, not just one signed fragment. @@ -46,25 +76,21 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew var action=complete?null:new Action(confirmed==null?ActionCode.RECORD_CONFIRMATION:ActionCode.CHANGE_BOOKING,confirmed==null?"维护原预约并登记确认":"核对或变更原预约",request.workItemKey(),new AppointmentTarget(a.id().toString(),a.batchKey(),sourceRefs)); var references=new ArrayList(); references.add(new BusinessReference("FBA_APPOINTMENT",a.batchKey(),a.appointmentNo(),"送仓预约")); rows.stream().filter(d->d.deliveryNo()!=null).map(d->new BusinessReference("DELIVERY",d.deliveryNo(),d.deliveryNo(),"实际派送")).distinct().forEach(references::add); - return new Fact(request.workItemKey(),digest(a.batchKey()+":"+a.version()+":"+rows),commitments.impactToken(a.batchKey()),true, + return new Fact(request.workItemKey(),digest(a.batchKey()+":"+a.version()+":"+rows),impact,true, confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,execution,target,confirmed,actual,a.timezone(),a.appointmentNo(),null, List.of(ORDER_READ),action==null?List.of():List.of(OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT),action,blocks, actual!=null?List.of("SOURCE_TIMEZONE"):List.of(),List.copyOf(references),null); } - private Fact unbatched(FactRequest request) { - if(request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) { - var mapped=sourceMapping.containerSources(request.containerBookingId()); - if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); - var related=mapped.stream().filter(s->request.requirementKey().equals("INBOUND:"+s.sourceInboundId())).toList(); - if(!related.isEmpty() && related.stream().allMatch(s->"PRIVATE".equals(s.destinationType()))) return customer(request,related); - } + private Fact unbatched(FactRequest request,Map inspections) { var blocks=new ArrayList(); var missing=new ArrayList(); Action action=null; var fingerprints=new ArrayList(); if(request.sources().isEmpty()) { blocks.add("尾程货源尚未建立,请先核对整柜来源与入仓计划"); missing.add("LASTLEG_SOURCE"); } else { for(var source:request.sources()) { try { - var context=cargo.inspect(source.sourceKey(),source.customerId(),source.destinationKey()); + var inspection=inspections.get(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())); + if(inspection==null) throw unavailable("预约货源批量读取不完整"); + var context=inspection.requireContext(); fingerprints.add(context.toString()); blocks.addAll(context.blockReasons()); } catch(org.springframework.web.server.ResponseStatusException error) { if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java index c2f68714f..c3cf9a918 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java @@ -7,8 +7,32 @@ import java.util.List; @Mapper public interface AppointmentWorkReceivingMapper { - @Select("SELECT DISTINCT p.* FROM ll_inbound_plan p JOIN ll_inbound_plan_line l ON l.plan_id=p.id JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id WHERE o.source_container_booking_id=#{container} ORDER BY p.id LIMIT 1001") - List plans(String container); - @Select("SELECT * FROM ll_receiving_container_session WHERE source_container_booking_id=#{container} ORDER BY created_at DESC,id DESC LIMIT 1") - ReceivingContainerSession session(String container); + class ContainerPlan extends InboundPlan { + private String sourceContainerBookingId; + public String getSourceContainerBookingId() { return sourceContainerBookingId; } + public void setSourceContainerBookingId(String value) { sourceContainerBookingId=value; } + } + @Select(""" + + """) + List plans(@Param("containers") List containers); + @Select(""" + + """) + List sessions(@Param("containers") List containers); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java index 30ac844ce..2a40e56f5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway; import com.baiyue.lastleg.security.OverseasPermissions; import org.springframework.stereotype.Component; import java.util.*; +import java.util.stream.Collectors; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -13,11 +14,15 @@ public class AppointmentWorkReceivingProvider implements AppointmentWorkFactGate private final AppointmentWorkReceivingMapper mapper; public AppointmentWorkReceivingProvider(AppointmentWorkReceivingMapper mapper) { this.mapper=mapper; } public boolean supports(Type type,Flow flow) { return type==Type.WAREHOUSE_RECEIPT && flow==Flow.TRANSFER; } - public List read(List requests) { return requests.stream().map(this::read).toList(); } - private Fact read(FactRequest request) { - var plans=mapper.plans(request.containerBookingId()); + public List read(List requests) { + if(requests.isEmpty()) return List.of(); + var ids=requests.stream().map(FactRequest::containerBookingId).distinct().toList(); + var plans=mapper.plans(ids).stream().collect(Collectors.groupingBy(AppointmentWorkReceivingMapper.ContainerPlan::getSourceContainerBookingId)); + var sessions=mapper.sessions(ids).stream().collect(Collectors.toMap(s->s.getSourceContainerBookingId(),s->s)); + return requests.stream().map(r->read(r,plans.getOrDefault(r.containerBookingId(),List.of()),sessions.get(r.containerBookingId()))).toList(); + } + private Fact read(FactRequest request,List plans,com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession session) { if(plans.size()>1000) throw unavailable("柜次接仓计划超过完整读取上限"); - var session=mapper.session(request.containerBookingId()); var dates=plans.stream().map(p->p.getPlannedArrivalDate()).filter(Objects::nonNull).distinct().sorted().toList(); var target=dates.isEmpty()?null:new Slot(dates.get(0),null,null,null,null); boolean complete=session!=null && session.getCompletedAt()!=null && Set.of("COMPLETED","COMPLETED_WITH_DIFFERENCE").contains(session.getStatus()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java index ac3503952..9a3c03a5f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java @@ -8,6 +8,20 @@ public interface AppointmentWorkSourceMapper { record Source(Long id,String customerId,String sourceOrderId,String sourceInboundId,String destinationType,String fbaWarehouseCode,Integer packageCount) { } @Select("SELECT id,customer_id,source_order_id,source_inbound_id,destination_type,fba_warehouse_code,package_count FROM ll_order WHERE source_container_booking_id=#{container} ORDER BY id LIMIT 1001") List containerSources(String container); + record ContainerSource(String containerBookingId,Long id,String customerId,String sourceOrderId,String sourceInboundId,String destinationType,String fbaWarehouseCode,Integer packageCount) { + public Source source() { return new Source(id,customerId,sourceOrderId,sourceInboundId,destinationType,fbaWarehouseCode,packageCount); } + } + @Select(""" + + """) + List containerSourcesBatch(@Param("containers") List containers); record DispatchSource(long dispatchId,String dispatchNo,String status,long orderId,String customerId,String sourceKey,String destinationKey,long packages) { } @Select("SELECT b.dispatch_id,d.dispatch_no,d.status,o.id order_id,b.customer_id,b.source_key,b.destination_key,SUM(b.package_count) packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id JOIN ll_order o ON b.destination_key=CONCAT('CUSTOMER:',o.id) AND b.customer_id=o.customer_id WHERE b.batch_key=CONCAT('DISPATCH:',d.id) AND o.source_container_booking_id=#{container} GROUP BY b.dispatch_id,d.dispatch_no,d.status,o.id,b.customer_id,b.source_key,b.destination_key ORDER BY b.dispatch_id,o.id LIMIT 1001") List privateDispatches(String container); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java new file mode 100644 index 000000000..ea89cc6e8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.util.stream.IntStream; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AppointmentCargoVerificationBatchTest { + @Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() { + var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); + var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); + var refs=IntStream.rangeClosed(1,200).mapToObj(i->new AppointmentCoverageService.SourceRef("C","ORDER:"+i,"FBA:ONT8")).toList(); + when(sources.inspectBatch(anyList())).thenAnswer(call->call.>getArgument(0).stream().map(ref-> + ref.sourceKey().equals("ORDER:2")?new AppointmentSourceService.Resolution(ref,null,new ResponseStatusException(HttpStatus.BAD_REQUEST,"接仓点不在启用的美国仓目录中")): + new AppointmentSourceService.Resolution(ref,new AppointmentSourceService.Source(ref.sourceKey(),ref.customerId(),Long.valueOf(ref.sourceKey().substring(6)),"IN",null,"CONTAINER","100","仓",ref.destinationKey(),"ONT8",10,"ORDER:1",true,List.of()),null)).toList()); + when(mapper.allocatedBatch(anyList())).thenAnswer(call->call.>getArgument(0).stream().filter(r->r.sourceKey().equals("ORDER:1")) + .map(r->new AppointmentCargoVerificationMapper.SourceAllocation(r.customerId(),r.sourceKey(),4,0)).toList()); + var result=service.inspectBatch(refs); + assertEquals(200,result.size());assertEquals(6L,result.get(0).requireContext().availablePackages()); + assertEquals("接仓点不在启用的美国仓目录中",assertThrows(ResponseStatusException.class,()->result.get(1).requireContext()).getReason()); + assertEquals(10L,result.get(199).requireContext().availablePackages()); + verify(sources,times(2)).inspectBatch(argThat(batch->batch.size()==100)); + verify(mapper,times(2)).latestBatch(anyList());verify(mapper,times(2)).allocatedBatch(anyList());verifyNoMoreInteractions(mapper,sources); + when(sources.inspectBatch(anyList())).thenThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目录不可用")); + assertEquals(503,assertThrows(ResponseStatusException.class,()->service.inspectBatch(refs)).getStatusCode().value()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java index de7061be3..21d7fd5bc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java @@ -31,6 +31,7 @@ class AppointmentCoverageMapperTest { s.execute("INSERT INTO ll_appointment_cargo_verification(id,customer_id,source_key,destination_key,version,package_count,shipment_references_json,operator_id,command_key,request_hash,created_at) VALUES(1,'A','ORDER:1','FBA:ONT8',1,19,'[]','operator','one',REPEAT('a',64),CURRENT_TIMESTAMP),(2,'A','ORDER:1','FBA:ONT8',2,20,'[\"FBA-A\"]','operator','two',REPEAT('b',64),CURRENT_TIMESTAMP),(3,'B','ORDER:1','FBA:ONT8',1,8,'[]','operator','one',REPEAT('c',64),CURRENT_TIMESTAMP)"); } var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentCoverageMapper.class); + config.addMapper(AppointmentCargoVerificationMapper.class); try(var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) { var mapper=session.getMapper(AppointmentCoverageMapper.class); var source=mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("A",1L))).get(0);assertFalse(source.shipmentReferencesMissing());assertEquals(20,source.packages()); @@ -42,6 +43,14 @@ class AppointmentCoverageMapperTest { assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing()); var evidence=mapper.verifications(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))); assertEquals(1,evidence.size());assertEquals(2,evidence.get(0).getVersion());assertEquals(20,evidence.get(0).getPackageCount()); + var cargo=session.getMapper(AppointmentCargoVerificationMapper.class); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("D","BOOKING:B-1","FBA:ONT8")); + var latest=cargo.latestBatch(refs);assertEquals(2,latest.size()); + assertEquals(2,latest.stream().filter(r->r.getCustomerId().equals("A")).findFirst().orElseThrow().getVersion()); + assertEquals(1,latest.stream().filter(r->r.getCustomerId().equals("B")).findFirst().orElseThrow().getVersion()); + var allocations=cargo.allocatedBatch(refs);assertEquals(2,allocations.size()); + assertEquals(new AppointmentCargoVerificationMapper.SourceAllocation("A","ORDER:1",20,0),allocations.stream().filter(r->r.customerId().equals("A")).findFirst().orElseThrow()); + assertEquals(new AppointmentCargoVerificationMapper.SourceAllocation("D","BOOKING:B-1",0,1),allocations.stream().filter(r->r.customerId().equals("D")).findFirst().orElseThrow()); } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java index c860204e2..0543d3efc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -25,6 +25,14 @@ class AppointmentSourceServiceTest { var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8")); var result=service.resolveBatch(refs);assertEquals(2,result.size());assertEquals(20,result.get(1).packages()); verify(orders,times(1)).selectBatchIds(anyCollection());verify(shipping,times(1)).selectBatchIds(anyCollection());verify(warehouse,times(1)).catalog("token");verify(gateway,times(1)).resolveOrders(anyList());verifyNoMoreInteractions(gateway); + b.setOverseasWarehouseId("unknown"); + var inspected=service.inspectBatch(refs);assertNotNull(inspected.get(0).requireSource()); + assertEquals("接仓点不在启用的美国仓目录中",inspected.get(1).error().getReason()); + assertThrows(ResponseStatusException.class,()->service.resolveBatch(refs)); + var unauthorized=service.inspectBatch(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1","FBA:ONT8"))); + assertEquals(404,assertThrows(ResponseStatusException.class,()->unauthorized.get(0).requireSource()).getStatusCode().value()); + when(warehouse.catalog("token")).thenThrow(new IllegalStateException("offline")); + assertEquals(503,assertThrows(ResponseStatusException.class,()->service.inspectBatch(refs)).getStatusCode().value()); assertThrows(ResponseStatusException.class,()->service.resolveBatch(List.of(refs.get(0),refs.get(0)))); assertThrows(ResponseStatusException.class,()->service.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1","FBA:ONT8")))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java index 8b0139a66..12f36bcc9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java @@ -35,6 +35,9 @@ class AppointmentWorkPersistenceTest { assertEquals("运营甲",store.replay(state.getWorkItemKey(),"same-key").getOperatorName()); assertThrows(RuntimeException.class,()->store.history(state,new Actor("2","运营乙",ACTOR.permissions()),"SAVE_FOLLOWUP","其他人重复键","same-key","other",Map.of("version",2))); assertEquals(1,store.history(state.getWorkItemKey()).size()); + mapper.insertCommitment("FBA_APPOINTMENT:1","{}");mapper.updateCommitment("FBA_APPOINTMENT:1","{}",3); + var tokens=new AppointmentWorkCommitmentService(mapper,new ObjectMapper()).impactTokens(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2")); + assertEquals(Map.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1:3","FBA_APPOINTMENT:2","FBA_APPOINTMENT:2:0"),tokens); } } @Test void transactionRollbackCannotLeaveHalfGeneratedRoute() throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java index 26a574500..6ce54bde2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java @@ -12,6 +12,60 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkQueryServiceTest { + @Test void followTodayPageOneSizeEightDoesNotReadUnrelatedRuntimeFactsOrPaginateBeforeCompletionChecks() { + var f=new WorkTestFixture(); + for(int index=0;index<1000;index++) f.add(f.state("unrelated-"+index,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null)); + var expected=new HashSet(); + var prior=f.state("due-1",Type.PICKUP,Flow.TRANSFER,null);f.add(prior);expected.add(prior.getWorkItemKey()); + for(int index=1;index<=10;index++) { + var state=f.state("due-"+index,Type.DELIVERY,Flow.TRANSFER,"inbound");state.setFollowupDate(LocalDate.of(2026,10,14)); + if(index==1) state.setPrerequisites(List.of(prior.getWorkItemKey())); + if(index==10) f.facts.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.COMPLETED,new Actual(Instant.parse("2026-10-14T12:00:00Z"),null,"America/Los_Angeles"))); + f.add(state);expected.add(state.getWorkItemKey()); + } + var future=f.state("future",Type.DELIVERY,Flow.TRANSFER,"inbound");future.setFollowupDate(LocalDate.of(2027,1,1));f.add(future); + // Exact parameters from /items?view=FOLLOW_TODAY&page=1&size=8. + var page=f.queries.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR); + assertEquals(9,page.total());assertEquals(8,page.records().size());assertEquals(2,page.pages()); + assertTrue(page.records().stream().allMatch(i->i.executionStatus()==ExecutionStatus.PENDING)); + verify(f.gateway).facts(argThat(states->states.stream().map(AppointmentWorkState::getWorkItemKey).collect(java.util.stream.Collectors.toSet()).equals(expected))); + } + @Test void followTodayPreservesAuthoritativeDateLineZonesAndUndatedPendingHandoffs() { + var f=new WorkTestFixture(); + var tomorrow=LocalDate.of(2026,10,16); + var east=f.state("east",Type.DELIVERY,Flow.TRANSFER,"inbound");east.setFollowupDate(tomorrow);f.add(east); + var west=f.state("west",Type.DELIVERY,Flow.TRANSFER,"inbound");west.setFollowupDate(tomorrow);f.add(west); + // Stored timezone is Los Angeles; the live source is already on the following local day. + f.facts.put(east.getWorkItemKey(),fact(east,new Slot(tomorrow,null,null,null,"Pacific/Kiritimati"),null,ExecutionStatus.PENDING,null)); + var handoff=f.state("return",Type.EMPTY_RETURN,Flow.TRANSFER,null); + var owner=new Responsibility(ResponsibilityKind.USER,"1","运营甲"); + handoff.setReturnHandoff(new ReturnHandoff(1,owner,owner,HandoffStatus.PENDING_ACCEPTANCE,"交接"));f.add(handoff); + var query=new AppointmentWorkQueryService(f.store,f.gateway,Clock.fixed(Instant.parse("2026-10-15T12:00:00Z"),ZoneOffset.UTC)); + var page=query.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR); + assertEquals(Set.of(east.getWorkItemKey(),handoff.getWorkItemKey()),page.records().stream().map(WorkItem::workItemKey).collect(java.util.stream.Collectors.toSet())); + } + @Test void calendarLoadsOnlyRequestedTypesButUsesCurrentAuthorityDatesAndStillChecksScope() { + var f=new WorkTestFixture(); + var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);receipt.setTarget(slot("2025-01-01"));f.add(receipt); + var pickup=f.state("1",Type.PICKUP,Flow.TRANSFER,null);f.add(pickup); + receipt.setPrerequisites(List.of(pickup.getWorkItemKey()));f.store.save(receipt); + f.facts.put(receipt.getWorkItemKey(),fact(receipt,slot("2026-10-16"),null,ExecutionStatus.PENDING,null)); + assertEquals(1,f.queries.calendar(LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),Flow.TRANSFER,Set.of(Type.WAREHOUSE_RECEIPT),ACTOR).total()); + verify(f.gateway).facts(argThat(states->states.size()==1 && states.get(0).getWorkItemKey().equals(receipt.getWorkItemKey()))); + verify(f.gateway).discover(null,1); + var reader=new Actor("2","无原业务权限",Set.of(LIST)); + assertEquals(0,f.queries.calendar(LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),null,Set.of(Type.WAREHOUSE_RECEIPT),reader).total()); + } + @Test void filteredItemsLoadPrerequisiteClosureWithoutIncludingUnrelatedContainers() { + var f=new WorkTestFixture(); + var pickup=f.state("1",Type.PICKUP,Flow.TRANSFER,null);f.add(pickup); + var retired=f.state("1",Type.ROUTE,Flow.UNKNOWN,null);retired.setRetired(true);retired.setReplacementKeys(List.of(pickup.getWorkItemKey()));f.add(retired); + var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);receipt.setPrerequisites(List.of(retired.getWorkItemKey()));receipt.setObservedFingerprint("old");f.add(receipt); + f.add(f.state("2",Type.DELIVERY,Flow.TRANSFER,"other")); + var items=f.queries.items(View.ALL,1,20,null,null,Set.of(Type.WAREHOUSE_RECEIPT),"1",null,null,ACTOR); + assertEquals(1,items.total());assertEquals(pickup.getWorkItemKey(),items.records().get(0).nextAction().targetWorkItemKey()); + verify(f.gateway).facts(argThat(states->states.stream().map(AppointmentWorkState::getWorkItemKey).collect(java.util.stream.Collectors.toSet()).equals(Set.of(pickup.getWorkItemKey(),receipt.getWorkItemKey())))); + } @Test void globalSummaryRetainsUnknownRoutesAndReturnAfterDeliveryAcrossCalendarFilters() { var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); route.setFollowupDate(LocalDate.of(2026,10,14)); f.add(route); var returns=f.state("2",Type.EMPTY_RETURN,Flow.DIRECT,null); returns.setTarget(slot("2026-09-01")); f.add(returns); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index a3d714739..2998c50ce 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -87,6 +87,8 @@ class FleetTripAuthorizationTest { } @Configuration @EnableMethodSecurity static class Config { + @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } + @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); } @Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); } @Bean FleetCapacityOfferCommandMapper commands() { return mock(FleetCapacityOfferCommandMapper.class); } @Bean FleetDispatchPlanningGateway planning() { return mock(FleetDispatchPlanningGateway.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProviderTest.java new file mode 100644 index 000000000..d4bf6e0e3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProviderTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.fleet.trip.work; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.stream.IntStream; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AppointmentWorkTransportProviderTest { + @Test void unassignedTransportWorkDoesNotQueryTripsOrWarehousesPerSource() { + var mapper=mock(AppointmentWorkTripMapper.class);var warehouses=mock(FleetWarehouseClient.class);var commitments=mock(AppointmentWorkCommitmentService.class); + var requests=IntStream.range(0,200).mapToObj(i->new FactRequest("W"+i,Type.TRANSPORT,Flow.TRANSFER,"C"+i,"INBOUND:"+i,null,null,List.of(),List.of(),List.of())).toList(); + var facts=new AppointmentWorkTransportProvider(mapper,warehouses,commitments,"token").read(requests); + assertEquals(200,facts.size());assertTrue(facts.stream().allMatch(f->!f.planExists() && f.executionStatus()==ExecutionStatus.PENDING)); + verifyNoInteractions(mapper,warehouses,commitments); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 412ae1062..269c14e49 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -62,6 +62,16 @@ class InboundFbaAppointmentServiceTest { SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(requestRefs(sources),"100","ONT8",details,key);} FbaAppointmentResponse create(String key) {return service.createFromSources("operator",request(key,pending(null),"ORDER:1","ORDER:2"));} + @Test void workBatchSnapshotsReadOnlyHeadersAndItemsWithoutRunningRepartitionEligibility() { + var original=create("snapshot"); + when(headers.selectBatchIds(List.of(original.id()))).thenReturn(List.copyOf(storedHeaders)); + clearInvocations(headers,items,query,sources); + assertEquals(List.of(original),service.batchSnapshots(List.of(original.batchKey()))); + verify(headers).selectBatchIds(List.of(original.id()));verify(items).selectList(any());verifyNoMoreInteractions(headers,items);verifyNoInteractions(query,sources); + assertThrows(ResponseStatusException.class,()->service.batchSnapshots(List.of("DISPATCH:1"))); + assertThrows(ResponseStatusException.class,()->service.batchSnapshots(List.of("FBA_APPOINTMENT:999"))); + } + @Test void sourceRequestKeepsWireShapeAndPassesAllSourceFieldsToResolution() throws Exception { var json=new ObjectMapper().findAndRegisterModules(); var input=request("source-json",pending(null),"ORDER:1","ORDER:2"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java index 3ea14af60..31b7d210f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java @@ -81,7 +81,9 @@ class InboundPlanPaginationTest { String sql = String.join("", InboundPlanQueryMapper.class .getMethod("rows", String.class, InboundPlanRequests.Query.class, long.class, long.class) .getAnnotation(org.apache.ibatis.annotations.Select.class).value()) + InboundPlanQueryMapper.FROM_AND_FILTER; - assertThat(sql).contains("ai2.customer_id=p.customer_id", "ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1"); + assertThat(sql).contains("ai2.customer_id=p.customer_id", "ai2.fba_warehouse_code=l.fba_warehouse_code", + "a2.replacement_nos_json IS NULL", "ORDER BY ai2.active DESC,a2.created_at DESC,ai2.id DESC LIMIT 1"); + assertThat(sql).contains("COUNT(DISTINCT current_item.appointment_id)", "current_item.active=1", "THEN 'MULTIPLE'"); assertThat(sql).contains("p.customer_id=#{customerId}", "o.customer_id=p.customer_id"); assertThat(sql).doesNotContain("a2.customer_id=#{customerId}"); assertThat(sql).contains("di.customer_id=p.customer_id AND di.order_id=l.order_id", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java index f1810f60b..fa0dce0c2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.inboundplan.work; import com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService; +import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef; +import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Resolution; import com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import org.junit.jupiter.api.Test; @@ -12,9 +14,28 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class AppointmentWorkDeliveryProviderTest { + @Test void establishedFbaBatchesShareSnapshotsDeliveriesAndImpactReads() { + var appointments=mock(InboundFbaAppointmentService.class);var cargo=mock(AppointmentCargoVerificationService.class); + var commitments=mock(AppointmentWorkCommitmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);var sources=mock(AppointmentWorkSourceMapper.class); + var requests=java.util.stream.IntStream.rangeClosed(1,200).mapToObj(i->new FactRequest("W"+i,Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:"+i,null,List.of(),List.of(),List.of())).toList(); + var keys=requests.stream().map(FactRequest::batchKey).toList(); + var snapshots=keys.stream().map(key->{ + var snapshot=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class); + when(snapshot.batchKey()).thenReturn(key);when(snapshot.id()).thenReturn(Long.valueOf(key.substring(16)));when(snapshot.appointmentStatus()).thenReturn("PENDING"); + return snapshot; + }).toList(); + when(appointments.batchSnapshots(keys)).thenReturn(snapshots); + when(commitments.impactTokens(keys)).thenReturn(keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"))); + var result=new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources).read(requests); + assertEquals(200,result.size());assertTrue(result.stream().allMatch(f->f.planExists() && f.executionStatus()==ExecutionStatus.PENDING)); + verify(appointments).batchSnapshots(keys);verify(deliveries).batches(keys);verify(commitments).impactTokens(keys); + verifyNoMoreInteractions(appointments,deliveries,commitments);verifyNoInteractions(cargo,sources); + when(appointments.batchSnapshots(keys)).thenReturn(List.of()); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources).read(requests)); + } @Test void handledPrerequisiteCommitsTheCallerTransactionThroughTheRealCargoProxy() { var sourceService=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.class); - when(sourceService.resolve("ORDER:11","C",null)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")); + when(sourceService.inspectBatch(List.of(new SourceRef("C","ORDER:11",null)))).thenReturn(List.of(new Resolution(new SourceRef("C","ORDER:11",null),null,new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")))); var cargo=new AppointmentCargoVerificationService(sourceService,mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationMapper.class),new com.fasterxml.jackson.databind.ObjectMapper()); var ds=new org.h2.jdbcx.JdbcDataSource();ds.setURL("jdbc:h2:mem:work-read-tx-"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1"); var manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds); @@ -33,11 +54,11 @@ class AppointmentWorkDeliveryProviderTest { var cargo=mock(AppointmentCargoVerificationService.class);var sources=mock(AppointmentWorkSourceMapper.class); var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),cargo,mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),sources); var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1","INBOUND:1",null,null,List.of(),List.of(),List.of(new SourceReference("C","ORDER:11",null))); - when(cargo.inspect("ORDER:11","C",null)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"接仓点不在启用的美国仓目录中")); + when(cargo.inspectBatch(List.of(new SourceRef("C","ORDER:11",null)))).thenReturn(List.of(new AppointmentCargoVerificationService.Inspection(new SourceRef("C","ORDER:11",null),null,new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"接仓点不在启用的美国仓目录中")))); var fact=provider.read(List.of(request)).get(0); assertEquals(ExecutionStatus.PENDING,fact.executionStatus());assertNull(fact.action());assertNull(fact.actual()); assertEquals(List.of("SOURCE_CONFIGURATION"),fact.missingFields());assertEquals(List.of("接仓点不在启用的美国仓目录中"),fact.blockReasons()); - doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"服务不可用")).when(cargo).inspect("ORDER:11","C",null); + doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"服务不可用")).when(cargo).inspectBatch(List.of(new SourceRef("C","ORDER:11",null))); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.read(List.of(request))); } @Test void privateCompletionCoversThisDispatchPerSourceWithoutCompletingTheWholeOrder() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java index 3a5824278..240464de9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java @@ -42,8 +42,31 @@ class AppointmentWorkDomainMapperTest { assertEquals(0,dispatch.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",1)); var deliveries=session.getMapper(AppointmentWorkDeliveryMapper.class);assertTrue(deliveries.batch("FBA_APPOINTMENT:1").isEmpty());assertTrue(deliveries.customer("1","1").isEmpty()); assertTrue(deliveries.privateBatch("DISPATCH:1").isEmpty()); + assertTrue(deliveries.batches(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2")).isEmpty()); var sources=session.getMapper(AppointmentWorkSourceMapper.class);assertTrue(sources.containerSources("1").isEmpty());assertTrue(sources.privateDispatches("1").isEmpty());assertTrue(sources.privateBatch("DISPATCH:1").isEmpty()); - var receiving=session.getMapper(AppointmentWorkReceivingMapper.class);assertTrue(receiving.plans("1").isEmpty());assertNull(receiving.session("1")); + var receiving=session.getMapper(AppointmentWorkReceivingMapper.class);assertTrue(receiving.plans(List.of("1")).isEmpty());assertTrue(receiving.sessions(List.of("1")).isEmpty()); + try(var sql=session.getConnection().createStatement()) { + sql.execute("INSERT INTO ll_order(id,order_no,customer_id,source_region,source_inbound_id,source_event_id,source_loading_plan_id,source_payload_hash,source_container_booking_id) VALUES(101,'O-1','A','TEST','I-1','E-1','P-1','HASH','1'),(102,'O-2','B','TEST','I-2','E-2','P-2','HASH','2')"); + sql.execute("INSERT INTO ll_inbound_plan(id,plan_no,customer_id,source_order_id,container_no,planned_arrival_date) VALUES(201,'IP-1','A',101,'C-1','2026-10-16'),(202,'IP-2','B',102,'C-2','2026-10-17')"); + sql.execute("INSERT INTO ll_inbound_plan_line(id,customer_id,plan_id,order_id,inbound_code) VALUES(301,'A',201,101,'IN-1'),(302,'B',202,102,'IN-2'),(303,'WRONG',201,102,'WRONG')"); + sql.execute("INSERT INTO ll_receiving_container_session(id,source_container_booking_id,container_fact_hash,container_no,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name,operator_key,status,created_at) VALUES(401,'1','HASH','C-1',1,'W','W','A','COMPLETED','2026-10-15 01:00:00'),(402,'1','HASH','C-1',1,'W','W','A','RECEIVING','2026-10-15 02:00:00'),(403,'1','HASH','C-1',1,'W','W','A','SIGNED','2026-10-15 02:00:00'),(404,'2','HASH','C-2',1,'W','W','B','WAIT_SIGN','2026-10-15 01:00:00')"); + } + session.clearCache(); + var plans=receiving.plans(List.of("1","2"));assertEquals(2,plans.size()); + assertEquals("1",plans.get(0).getSourceContainerBookingId());assertEquals("IP-1",plans.get(0).getPlanNo());assertEquals(java.time.LocalDate.of(2026,10,16),plans.get(0).getPlannedArrivalDate()); + assertEquals("2",plans.get(1).getSourceContainerBookingId()); + var sourceBatch=sources.containerSourcesBatch(List.of("1","2"));assertEquals(2,sourceBatch.size()); + assertEquals("1",sourceBatch.get(0).containerBookingId());assertEquals(sources.containerSources("1").get(0),sourceBatch.get(0).source()); + var sessions=receiving.sessions(List.of("1","2"));assertEquals(2,sessions.size()); + assertEquals(403L,sessions.stream().filter(s->"1".equals(s.getSourceContainerBookingId())).findFirst().orElseThrow().getId()); + try(var sql=session.getConnection().createStatement()) { + sql.execute("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(501,'A','D-1','PENDING_DEPARTURE'),(502,'B','D-2','CANCELLED'),(503,'B','D-3','PENDING_DEPARTURE')"); + sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','A','ORDER:101','FBA:ONT8',4),(501,602,702,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(502,603,703,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',2),(503,604,704,'FBA_APPOINTMENT:2','B','ORDER:102','FBA:ONT8',3)"); + } + session.clearCache(); + var batchDeliveries=deliveries.batches(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2"));assertEquals(3,batchDeliveries.size()); + assertEquals(deliveries.batch("FBA_APPOINTMENT:1"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:1")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList()); + assertEquals(deliveries.batch("FBA_APPOINTMENT:2"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:2")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList()); var trips=session.getMapper(AppointmentWorkTripMapper.class);assertTrue(trips.batchTrips("FBA_APPOINTMENT:1").isEmpty());assertTrue(trips.batches(1).isEmpty());assertNull(trips.trip(1)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java index 433d3cc22..872d4036e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java @@ -13,9 +13,10 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; class AppointmentWorkReceivingProviderTest { @Test void receivingIsInProgressWithoutInventingAnActualCompletionAndTargetCarriesRealIdentity() { var mapper=mock(AppointmentWorkReceivingMapper.class); var provider=new AppointmentWorkReceivingProvider(mapper); - var plan=new InboundPlan(); plan.setId(10L);plan.setPlanNo("IP-REAL");plan.setCustomerId("C");plan.setPlannedArrivalDate(LocalDate.of(2026,10,16)); + var plan=new AppointmentWorkReceivingMapper.ContainerPlan(); plan.setSourceContainerBookingId("1");plan.setId(10L);plan.setPlanNo("IP-REAL");plan.setCustomerId("C");plan.setPlannedArrivalDate(LocalDate.of(2026,10,16)); var session=new ReceivingContainerSession();session.setId(2L);session.setStatus("RECEIVING");session.setReceivingAt(LocalDateTime.of(2026,10,15,10,0)); - when(mapper.plans("1")).thenReturn(List.of(plan)); when(mapper.session("1")).thenReturn(session); + session.setSourceContainerBookingId("1"); + when(mapper.plans(List.of("1"))).thenReturn(List.of(plan)); when(mapper.sessions(List.of("1"))).thenReturn(List.of(session)); var request=new FactRequest("work",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,"1",null,null,null,List.of(),List.of(),List.of()); var fact=provider.read(List.of(request)).get(0); assertEquals(ExecutionStatus.IN_PROGRESS,fact.executionStatus()); assertNull(fact.actual()); @@ -23,4 +24,14 @@ class AppointmentWorkReceivingProviderTest { session.setStatus("COMPLETED"); session.setCompletedAt(LocalDateTime.of(2026,10,16,12,0)); fact=provider.read(List.of(request)).get(0); assertEquals(ExecutionStatus.COMPLETED,fact.executionStatus()); assertEquals(session.getCompletedAt(),fact.actual().localOccurredAt()); } + @Test void twoQueriesCoverTwoHundredContainersAndKeepTheirFactsSeparate() { + var mapper=mock(AppointmentWorkReceivingMapper.class); var provider=new AppointmentWorkReceivingProvider(mapper); + var requests=java.util.stream.IntStream.range(1,201).mapToObj(i->new FactRequest("W"+i,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,""+i,null,null,null,List.of(),List.of(),List.of())).toList(); + var plan=new AppointmentWorkReceivingMapper.ContainerPlan();plan.setSourceContainerBookingId("2");plan.setId(20L);plan.setPlanNo("IP-2");plan.setPlannedArrivalDate(LocalDate.of(2026,10,16)); + when(mapper.plans(org.mockito.ArgumentMatchers.anyList())).thenReturn(List.of(plan)); + var facts=provider.read(requests);assertEquals(200,facts.size());assertNull(facts.get(0).target());assertEquals(plan.getPlannedArrivalDate(),facts.get(1).target().date()); + verify(mapper).plans(requests.stream().map(FactRequest::containerBookingId).toList());verify(mapper).sessions(requests.stream().map(FactRequest::containerBookingId).toList());verifyNoMoreInteractions(mapper); + when(mapper.plans(org.mockito.ArgumentMatchers.anyList())).thenReturn(java.util.Collections.nCopies(1001,plan)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.read(requests)); + } }