perf(lastleg): batch appointment requirement generation reads

This commit is contained in:
2026-10-17 12:54:39 +08:00
parent 662dead053
commit b0acbfbb34
21 changed files with 747 additions and 66 deletions
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.appointmentplanning;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 单次生成只批量读取核实与分配原料,业务解析仍按实际访问顺序。 */
@Repository
public class AppointmentCargoReadRepository {
private final AppointmentCargoVerificationMapper mapper;
/** 复用本域既有批量查询。 */
public AppointmentCargoReadRepository(AppointmentCargoVerificationMapper mapper) { this.mapper=mapper; }
/** 取得每客户来源的最新核实记录,暂不解析货件 JSON。 */
public List<AppointmentCargoVerification> latest(List<AppointmentCoverageService.SourceRef> refs) { return refs.isEmpty()?List.of():mapper.latestBatch(refs); }
/** 分配数量仅取活动明细,未知数量保持单独计数。 */
public List<AppointmentCargoVerificationMapper.SourceAllocation> allocations(List<AppointmentCoverageService.SourceRef> refs) { return refs.isEmpty()?List.of():mapper.allocatedBatch(refs); }
}
@@ -21,6 +21,7 @@ public class AppointmentCargoVerificationService {
private final AppointmentSourceService sources;
private final AppointmentCargoVerificationMapper mapper;
private final ObjectMapper json;
@jakarta.annotation.Resource private AppointmentCargoReadRepository readRepository;
public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper,
ObjectMapper json) {
@@ -50,6 +51,23 @@ public class AppointmentCargoVerificationService {
public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) {
public Context requireContext() { if(error!=null) throw error; return context; }
}
/** 只预取本次已授权来源的原料;核实 JSON 与数量判断按原 anyMatch 实际访问顺序执行。 */
public java.util.function.Function<AppointmentSourceService.Source,Context> prepareInspection(List<AppointmentSourceService.Source> resolved) {
var refs=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList();
var latest=new HashMap<List<String>,AppointmentCargoVerification>();
var allocations=new HashMap<List<String>,AppointmentCargoVerificationMapper.Allocation>();
for(int start=0;start<refs.size();start+=100) {
var batch=refs.subList(start,Math.min(start+100,refs.size()));
readRepository.latest(batch).forEach(r->latest.put(List.of(r.getCustomerId(),r.getSourceKey()),r));
readRepository.allocations(batch).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems())));
}
var allowed=new HashSet<>(resolved);
return source->{
if(!allowed.contains(source)) throw new IllegalArgumentException("货物核实来源不属于本次读取范围");
var key=List.of(source.customerId(),source.sourceKey());
return inspect(source,latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0)));
};
}
/** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */
public List<Inspection> inspectBatch(List<AppointmentCoverageService.SourceRef> refs) {
var result=new ArrayList<Inspection>();
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.entity.ContainerShippingGate;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 同次需求生成的来源原料读取,不保存跨调用状态。 */
@Repository
public class AppointmentSourceReadRepository {
private final LastlegOrderMapper orders;
private final ContainerShippingGateMapper shipping;
private final AppointmentCoverageMapper destinations;
/** 仅组合本域既有批量持久查询。 */
public AppointmentSourceReadRepository(LastlegOrderMapper orders,ContainerShippingGateMapper shipping,AppointmentCoverageMapper destinations) {
this.orders=orders;this.shipping=shipping;this.destinations=destinations;
}
/** 读取所选订单,客户归属继续由来源服务逐需求核验。 */
public List<LastlegOrder> orders(List<Long> ids) { return ids.isEmpty()?List.of():orders.selectBatchIds(ids); }
/** 读取实际船节点门禁,不从计划时间推断。 */
public List<ContainerShippingGate> gates(List<String> ids) { return ids.isEmpty()?List.of():shipping.selectBatchIds(ids); }
/** 转运目的地仍取原业务权威关系。 */
public List<AppointmentCoverageMapper.Source> destinations(List<AppointmentCoverageMapper.OrderRef> refs) { return refs.isEmpty()?List.of():destinations.sources(refs); }
}
@@ -26,6 +26,7 @@ public class AppointmentSourceService {
private final AppointmentWarehouseClient warehouses;
private final String token;
@jakarta.annotation.Resource private AppointmentCoverageMapper destinationFacts;
@jakarta.annotation.Resource private AppointmentSourceReadRepository readRepository;
public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping,
AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses,
@Value("${baiyue.security.internal-service-token:}") String token) {
@@ -72,12 +73,96 @@ public class AppointmentSourceService {
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList();
}
/** 同页只合并原料查询;来源规范化、重复与权限检查仍按每份需求独立执行。 */
public List<OrderGroup> resolveOrderGroups(List<List<AppointmentExternalSourceGateway.OrderRef>> groups) {
var canonical=new java.util.ArrayList<List<AppointmentCoverageService.SourceRef>>();
var errors=new java.util.ArrayList<RuntimeException>();
var original=new LinkedHashSet<AppointmentExternalSourceGateway.OrderRef>();
for(var group:groups) {
RuntimeException error=null;
if(group==null || group.isEmpty() || group.size()>100) error=new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Firstleg需求缺少完整客户货源关系");
else if(group.stream().anyMatch(r->r==null || blank(r.customerId()) || r.orderId()==null || r.orderId()<=0)) error=bad("请选择完整的真实货源");
errors.add(error);if(error==null) original.addAll(group);
}
var aliases=new java.util.HashMap<AppointmentExternalSourceGateway.OrderRef,AppointmentExternalSourceGateway.Source>();
var originals=new java.util.ArrayList<>(original);
for(int start=0;start<originals.size();start+=100) {
for(var booking:external.resolveOrders(originals.subList(start,Math.min(start+100,originals.size())))) {
var key=new AppointmentExternalSourceGateway.OrderRef(booking.customerId(),booking.orderId());
if(aliases.put(key,booking)!=null) throw new IllegalStateException("外接原订单关联重复");
}
}
var bookingRefs=new LinkedHashSet<AppointmentExternalSourceGateway.SourceRef>();
var orderIds=new LinkedHashSet<Long>();
for(int i=0;i<groups.size();i++) {
if(errors.get(i)!=null) { canonical.add(List.of());continue; }
var refs=groups.get(i).stream().distinct().map(ref->{
var alias=aliases.get(ref);
return new AppointmentCoverageService.SourceRef(ref.customerId(),alias==null?"ORDER:"+ref.orderId():"BOOKING:"+alias.bookingNo(),null);
}).toList();
// 保留原 canonical 的数量校验,不能以全页去重掩盖单需求重复。
if(refs.size()!=groups.get(i).size()) errors.set(i,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源键必须明确为ORDER或BOOKING"));
refs=refs.stream().distinct().toList();canonical.add(refs);
for(var ref:refs) if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey()));
else bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8)));
}
var bookingMap=new java.util.HashMap<String,AppointmentExternalSourceGateway.Source>();
var bookings=new java.util.ArrayList<>(bookingRefs);
for(int start=0;start<bookings.size();start+=100) for(var booking:external.resolve(bookings.subList(start,Math.min(start+100,bookings.size())))) {
if(bookingMap.put(booking.customerId()+"\n"+booking.bookingNo(),booking)!=null) throw bad("外接来源重复,请先核对");
if(booking.orderId()!=null) orderIds.add(booking.orderId());
}
var orderMap=new java.util.LinkedHashMap<Long,com.baiyue.lastleg.entity.LastlegOrder>();
var gates=new java.util.HashMap<String,Boolean>();
var destinations=new java.util.HashMap<Long,AppointmentCoverageMapper.Source>();
var ids=new java.util.ArrayList<>(orderIds);
for(int start=0;start<ids.size();start+=100) {
var rows=readRepository.orders(ids.subList(start,Math.min(start+100,ids.size())));
rows.forEach(row->{if(orderMap.put(row.getId(),row)!=null) throw new IllegalStateException("原订单来源重复");});
}
var containers=orderMap.values().stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList();
for(int start=0;start<containers.size();start+=100) readRepository.gates(containers.subList(start,Math.min(start+100,containers.size()))).forEach(g->{
if(gates.put(g.getContainerBookingId(),g.getNodeSequence()!=null && g.getNodeSequence()>=40)!=null) throw new IllegalStateException("原船节点门禁重复");
});
var transfers=orderMap.values().stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList();
for(int start=0;start<transfers.size();start+=100) readRepository.destinations(transfers.subList(start,Math.min(start+100,transfers.size()))).forEach(d->{
if(destinations.put(d.orderId(),d)!=null) throw new IllegalStateException("原目的地来源重复");
});
var result=new java.util.ArrayList<OrderGroup>();RuntimeException infrastructure=null;
for(int i=0;i<groups.size();i++) {
var refs=canonical.get(i);RuntimeException error=errors.get(i);var resolved=new java.util.ArrayList<Source>();
if(error==null) error=infrastructure;
if(error==null) try {
var keys=new java.util.HashSet<String>();var materialized=new java.util.HashSet<Long>();
for(var ref:refs) {
if(blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源");
if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择");
}
var directory=catalog();
for(var ref:refs) {
var source=resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,directory,destinations);
if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源");
resolved.add(source);
}
} catch(RuntimeException failed) {
error=failed;
// 基础设施失败不重试、不变为空数据,延后到原需求访问位置抛出。
if(!(failed instanceof ResponseStatusException status) || status.getStatusCode().value()>=500) infrastructure=failed;
}
result.add(new OrderGroup(refs,List.copyOf(resolved),error));
}
return List.copyOf(result);
}
/** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */
private List<AppointmentWarehouseClient.Warehouse> catalog() {
if(directory==null) directory=AppointmentSourceService.this.catalog();
return directory;
}
}
public record OrderGroup(List<AppointmentCoverageService.SourceRef> references,List<Source> sources,RuntimeException error) {
/** 业务前置错误仍在原需求解析位置抛出,由原边界决定是否保留未就绪需求。 */
public List<Source> requireSources() { if(error!=null) throw error;return sources; }
}
/** 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; }
@@ -117,7 +117,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); }
}
/** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */
public RequirementReader openRequirements() {
public RequirementReader openRequirements(List<RequirementInput> inputs) {
return new RequirementReader() {
private RequirementReader reader;
/** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */
@@ -125,7 +125,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
if(reader==null) {
var provider=requirements.getIfAvailable();
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
reader=provider.openRequirements();
reader=provider.openRequirements(inputs);
}
var result=reader.read(source,effectiveFlow);
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
@@ -28,7 +28,7 @@ public interface AppointmentWorkFactGateway {
SourcePage discover(String after, int size);
ContainerSource container(String containerBookingId);
/** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */
RequirementReader openRequirements();
RequirementReader openRequirements(List<RequirementInput> inputs);
Map<String, Fact> facts(List<AppointmentWorkState> states);
Owner owner(String id, Actor actor);
Responsibility supplier(String id);
@@ -43,5 +43,6 @@ public interface AppointmentWorkFactGateway {
interface SupplierDirectory { Responsibility activeSupplier(String id); }
/** 单次生成显式复用目录,不跨请求保存来源事实。 */
interface RequirementReader { List<Requirement> read(ContainerSource source,Flow effectiveFlow); }
interface RequirementProvider { RequirementReader openRequirements(); }
record RequirementInput(ContainerSource source,Flow flow) { }
interface RequirementProvider { RequirementReader openRequirements(List<RequirementInput> inputs); }
}
@@ -36,7 +36,16 @@ public class AppointmentWorkSyncService {
timing.next(SyncPhase.GENERATE);
// 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。
var known=workingSet();
var requirementReader=gateway.openRequirements();
var requirementInputs=page.containers().stream().filter(source->{
// 坏来源仍由下面原顺序校验并报错,不能在后页原料提示阶段抢先覆盖前柜错误。
try { validateSource(source);return true; } catch(RuntimeException invalid) { return false; }
}).map(source->{
var flow=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId())))
.filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(source.establishedFlow());
return new RequirementInput(source,flow);
}).filter(input->input.flow()!=Flow.UNKNOWN).toList();
// 提示仅用于原料批量读取,下面逐柜 existing/activeFlow 仍根据最新工作集决定。
var requirementReader=gateway.openRequirements(requirementInputs);
int created=0; var ids=new HashSet<String>();
for(var source:page.containers()) {
validateSource(source);
@@ -131,7 +140,7 @@ public class AppointmentWorkSyncService {
}
/** 命令入口仍在调用方守卫事务中即时加载工作集。 */
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements());
return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements(List.of(new RequirementInput(source,flow))));
}
/** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */
private Map<String,AppointmentWorkState> workingSet() {
@@ -28,6 +28,28 @@ public interface InboundFbaAppointmentQueryMapper {
" ORDER BY appointment_id DESC LIMIT 1001</script>"})
List<Long> relatedBatchIds(@Param("sources") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> sources);
record RequirementSource(int groupOrdinal,String customerId,String sourceKey,String destinationKey) { }
record RequirementBatch(int groupOrdinal,long appointmentId) { }
/** 按原需求组返回历史归属,失活明细也保留;每组第1001条用于检测超限。 */
@Select("""
<script>
SELECT group_ordinal,appointment_id FROM (
SELECT matched.*,ROW_NUMBER() OVER(PARTITION BY group_ordinal ORDER BY appointment_id DESC) work_row FROM (
SELECT DISTINCT requested.group_ordinal,i.appointment_id
FROM ll_inbound_fba_appointment_item i JOIN (
<foreach collection='sources' item='s' separator=' UNION ALL '>
SELECT CAST(#{s.groupOrdinal} AS SIGNED) group_ordinal,
CAST(#{s.customerId} AS CHAR(128)) customer_id,CAST(#{s.sourceKey} AS CHAR(160)) source_key,
CAST(#{s.destinationKey} AS CHAR(160)) destination_key
</foreach>
) requested ON requested.customer_id=i.customer_id AND requested.source_key=i.source_key
AND requested.destination_key=CONCAT('FBA:',i.fba_warehouse_code)
) matched
) ranked WHERE work_row &lt;= 1001 ORDER BY group_ordinal,appointment_id DESC
</script>
""")
List<RequirementBatch> requirementBatches(@Param("sources") List<RequirementSource> sources);
@Select("<script>SELECT COUNT(*) FROM ll_order o "+com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.TRANSFER_JOIN+
" WHERE o.id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> AND ("+
com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.SHIPMENT_MISSING+") </script>")
@@ -34,6 +34,7 @@ public class InboundFbaAppointmentService {
@Resource private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving;
@Resource private com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort receivingWork;
@Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges;
@Resource private com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads;
@Transactional(readOnly=true)
public List<FbaAppointmentResponse> calendar(LocalDate from,LocalDate to) {
@@ -111,6 +112,42 @@ public class InboundFbaAppointmentService {
if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对");
return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids));
}
/** 同页原料只读一次,历史归属按原需求组保留;响应解析延后至原访问顺序。 */
public java.util.function.IntFunction<List<FbaAppointmentResponse>> prepareRelatedBatchSnapshots(List<List<AppointmentCoverageService.SourceRef>> groups) {
var errors=new HashMap<Integer,RuntimeException>();
var requested=new ArrayList<InboundFbaAppointmentQueryMapper.RequirementSource>();
for(int i=0;i<groups.size();i++) {
var refs=groups.get(i);
if(refs==null || refs.isEmpty()) continue;
if(refs.size()>100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) {
errors.put(i,bad("每次最多读取100个完整原来源的关联批次"));continue;
}
final int group=i;
refs.stream().distinct().forEach(ref->requested.add(new InboundFbaAppointmentQueryMapper.RequirementSource(group,ref.customerId(),ref.sourceKey(),ref.destinationKey())));
}
var groupIds=new HashMap<Integer,Set<Long>>();
for(int start=0;start<requested.size();start+=100) {
for(var match:requirementReads.history(requested.subList(start,Math.min(start+100,requested.size())))) {
if(match.groupOrdinal()<0 || match.groupOrdinal()>=groups.size()) throw new IllegalStateException("历史关联返回了请求外需求组");
groupIds.computeIfAbsent(match.groupOrdinal(),ignored->new TreeSet<>()).add(match.appointmentId());
}
}
groupIds.forEach((group,ids)->{if(ids.size()>1000) errors.put(group,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"));});
var ids=groupIds.entrySet().stream().filter(e->!errors.containsKey(e.getKey())).flatMap(e->e.getValue().stream()).distinct().sorted().toList();
var rows=new LinkedHashMap<Long,InboundFbaAppointment>();
var items=new HashMap<Long,List<InboundFbaAppointmentItem>>();
for(int start=0;start<ids.size();start+=200) {
var batch=ids.subList(start,Math.min(start+200,ids.size()));
requirementReads.appointments(batch).forEach(row->rows.put(row.getId(),row));
requirementReads.items(batch).forEach(item->items.computeIfAbsent(item.getAppointmentId(),ignored->new ArrayList<>()).add(item));
}
return group->{
if(group<0 || group>=groups.size()) throw new IllegalArgumentException("需求组不属于本次读取范围");
if(errors.containsKey(group)) throw errors.get(group);
var selected=groupIds.getOrDefault(group,Set.of());
return rows.values().stream().filter(row->selected.contains(row.getId())).map(row->response(row,items.getOrDefault(row.getId(),List.of()))).toList();
};
}
@Transactional(readOnly=true)
public FbaAppointmentBatchContext batchContext(String key) {
final long id;
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.inboundplan.entity.*;
import com.baiyue.lastleg.inboundplan.mapper.*;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 需求生成的本域原料读取,历史归属不能从活动响应明细反推。 */
@Repository
public class AppointmentRequirementReadRepository {
private final AppointmentWorkSourceMapper mapping;
private final InboundFbaAppointmentQueryMapper queries;
private final InboundFbaAppointmentMapper appointments;
private final InboundFbaAppointmentItemMapper items;
/** 保留本域 Mapper 与客户来源查询边界。 */
public AppointmentRequirementReadRepository(AppointmentWorkSourceMapper mapping,InboundFbaAppointmentQueryMapper queries,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items) {
this.mapping=mapping;this.queries=queries;this.appointments=appointments;this.items=items;
}
/** 每柜独立上限由既有窗口查询保持。 */
public List<AppointmentWorkSourceMapper.ContainerSource> sources(List<String> containers) { return containers.isEmpty()?List.of():mapping.containerSourcesBatch(containers); }
/** 分块只控制单条 SQL 输入,业务服务仍按需求组完整合并及核对上限。 */
public List<InboundFbaAppointmentQueryMapper.RequirementBatch> history(List<InboundFbaAppointmentQueryMapper.RequirementSource> sources) { return sources.isEmpty()?List.of():queries.requirementBatches(sources); }
/** 读取已确认关联的预约主记录。 */
public List<InboundFbaAppointment> appointments(List<Long> ids) { return ids.isEmpty()?List.of():appointments.selectBatchIds(ids); }
/** 包括失活明细以维持取消与替代历史;响应过滤由业务服务执行。 */
public List<InboundFbaAppointmentItem> items(List<Long> ids) { return ids.isEmpty()?List.of():items.selectList(new LambdaQueryWrapper<InboundFbaAppointmentItem>().in(InboundFbaAppointmentItem::getAppointmentId,ids).orderByAsc(InboundFbaAppointmentItem::getId)); }
}
@@ -17,29 +17,85 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
private final AppointmentCargoVerificationService cargo;
private final InboundFbaAppointmentService appointments;
private final AppointmentWorkSourceMapper sourceMapping;
@jakarta.annotation.Resource private AppointmentRequirementReadRepository reads;
public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) {
this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping;
}
/** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */
public RequirementReader openRequirements() {
/** 本次生成共享原料,实例不会跨同步页或独立命令保存。 */
public RequirementReader openRequirements(List<RequirementInput> inputs) {
return new RequirementReader() {
private AppointmentSourceService.ReadScope scope;
/** 直送和未建立尾程来源的需求不创建目录读取依赖。 */
private Prepared prepared;
/** 原料提示不替代实际路线判断;未提示的真实需求按同一实现即时读取。 */
public List<Requirement> read(ContainerSource container,Flow flow) {
return requirements(container,flow,refs->{
if(scope==null) scope=sources.openReadScope();
return scope.resolveBatch(refs);
});
if(flow==Flow.DIRECT) return original(container);
if(flow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线");
if(prepared==null) {
var requested=new ArrayList<>(inputs);requested.add(new RequirementInput(container,flow));
prepared=prepare(requested);
}
var data=prepared.containers.contains(container)?prepared:prepare(List.of(new RequirementInput(container,flow)));
return requirements(container,flow,data);
}
};
}
/** 私人地址仍以原客户仓需求表示,直送不增加尾程读取依赖。 */
private List<Requirement> original(ContainerSource container) {
return container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList();
}
private record Group(ContainerSource container,String requirementKey) { }
/** 只含本次原料及延后执行器,不缓存已经解析的业务响应。 */
private static final class Prepared {
private final Set<ContainerSource> containers=new LinkedHashSet<>();
private final Map<String,List<AppointmentWorkSourceMapper.Source>> mapped=new HashMap<>();
private final Map<Group,Integer> ordinals=new LinkedHashMap<>();
private List<AppointmentSourceService.OrderGroup> sources=List.of();
private RuntimeException mappingError,sourceError;
private java.util.function.IntFunction<List<FbaAppointmentResponse>> history;
private java.util.function.Function<AppointmentSourceService.Source,AppointmentCargoVerificationService.Context> inspection;
}
/** 先批量读取原料,组级业务错误在原需求访问位置抛出。 */
private Prepared prepare(List<RequirementInput> inputs) {
var data=new Prepared();
inputs.stream().filter(i->i.flow()==Flow.TRANSFER).map(RequirementInput::source).forEach(data.containers::add);
var ids=data.containers.stream().map(ContainerSource::containerBookingId).distinct().toList();
try {
for(int start=0;start<ids.size();start+=100) reads.sources(ids.subList(start,Math.min(start+100,ids.size())))
.forEach(row->data.mapped.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.source()));
} catch(RuntimeException error) { data.mappingError=error;return data; }
var groups=new ArrayList<List<AppointmentExternalSourceGateway.OrderRef>>();
for(var container:data.containers) {
var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of());
if(mapped.size()>1000) continue;
for(var requirement:original(container)) {
if(!"FBA".equals(requirement.destinationKind())) continue;
var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null;
var orders=mapped.stream().filter(row->Objects.equals(row.sourceInboundId(),inbound)).toList();
if(orders.isEmpty()) continue;
data.ordinals.put(new Group(container,requirement.requirementKey()),groups.size());
groups.add(orders.stream().map(row->new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.id())).toList());
}
}
if(groups.isEmpty()) return data;
try { data.sources=sources.openReadScope().resolveOrderGroups(groups); }
catch(RuntimeException error) { data.sourceError=error;return data; }
var historyGroups=data.sources.stream().map(group->group.error()==null?group.sources().stream().map(source->new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList():List.<AppointmentCoverageService.SourceRef>of()).toList();
var valid=data.sources.stream().filter(group->group.error()==null).flatMap(group->group.sources().stream()).toList();
if(valid.isEmpty()) return data;
try { data.history=appointments.prepareRelatedBatchSnapshots(historyGroups); }
catch(RuntimeException error) { data.history=ignored->{throw error;}; }
// 不提前解析货件,仍由原 history -> anyMatch 路径按实际访问顺序执行。
try { data.inspection=cargo.prepareInspection(valid); }
catch(RuntimeException error) { data.inspection=ignored->{throw error;}; }
return data;
}
/** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */
private List<Requirement> requirements(ContainerSource container,Flow effectiveFlow,
java.util.function.Function<List<AppointmentCoverageService.SourceRef>,List<AppointmentSourceService.Source>> resolver) {
var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList();
Prepared data) {
var original=original(container);
if(effectiveFlow==Flow.DIRECT) return original;
if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线");
var mapped=sourceMapping.containerSources(container.containerBookingId());
if(data.mappingError!=null) throw data.mappingError;
var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of());
if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限");
var result=new LinkedHashMap<String,Requirement>();
var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.<AppointmentWorkSourceMapper.DispatchSource>of();
@@ -50,9 +106,13 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; }
if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; }
if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; }
var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList());
if(sourceOrders.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系");
if(data.sourceError!=null) throw data.sourceError;
int ordinal=data.ordinals.get(new Group(container,requirement.requirementKey()));
var group=data.sources.get(ordinal);
var refs=group.references();
List<AppointmentSourceService.Source> resolved;
try { resolved=resolver.apply(refs); }
try { resolved=group.requireSources(); }
catch(org.springframework.web.server.ResponseStatusException error) {
if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error;
// Keep the complete physical obligation and its canonical source identities. The fact
@@ -63,10 +123,10 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
continue;
}
var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList();
var history=appointments.relatedBatchSnapshots(complete);
var history=data.history.apply(ordinal);
var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList();
// Unknown remaining quantity is still work, never a zero or an absent requirement.
boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0);
boolean remainder=resolved.stream().map(data.inspection).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0);
var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList();
if(remainder) {
var restored=new LinkedHashSet<>(replaced);
@@ -128,18 +188,6 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList()));
}
}
private List<AppointmentCoverageService.SourceRef> canonical(List<SourceReference> input) {
if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系");
var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{
try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); }
catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); }
}).toList();
var result=new ArrayList<AppointmentCoverageService.SourceRef>();
if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders));
input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add);
if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING");
return result.stream().distinct().toList();
}
/** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */
private static boolean active(FbaAppointmentResponse batch) {
if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false;