2026-10-09 22:04:49 +08:00
|
|
|
package com.baiyue.lastleg.appointmentplanning;
|
|
|
|
|
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
import java.util.*;
|
|
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
|
|
|
|
|
/** Shared read projection. Callers authorize the customer/source before invoking; never confirms bookings. */
|
|
|
|
|
@Service
|
|
|
|
|
public class AppointmentCoverageService {
|
|
|
|
|
public record SourceRef(String customerId, String sourceKey, String destinationKey) { }
|
|
|
|
|
public record Appointment(String appointmentNo, String confirmationNo, String status,
|
|
|
|
|
LocalDateTime confirmedAt, LocalDateTime confirmedEndAt, String timezone) { }
|
|
|
|
|
public record Coverage(String customerId, String sourceKey, String destinationKey, String status,
|
|
|
|
|
List<Appointment> appointments, List<String> missingFields,
|
|
|
|
|
String sourceVersion, boolean changeReviewRequired) { }
|
|
|
|
|
private final AppointmentCoverageMapper mapper;
|
|
|
|
|
private final AppointmentExternalSourceGateway external;
|
|
|
|
|
public AppointmentCoverageService(AppointmentCoverageMapper mapper, AppointmentExternalSourceGateway external) {
|
|
|
|
|
this.mapper=mapper; this.external=external;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public List<Coverage> query(List<SourceRef> input) {
|
|
|
|
|
if (input==null || input.isEmpty()) return List.of();
|
|
|
|
|
if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) {
|
|
|
|
|
throw new IllegalArgumentException("每次最多核对100个完整的预约来源");
|
|
|
|
|
}
|
|
|
|
|
var requested=input.stream().distinct().toList();
|
|
|
|
|
var externalRefs=requested.stream().filter(s -> s.sourceKey().startsWith("BOOKING:"))
|
|
|
|
|
.map(s -> new AppointmentExternalSourceGateway.SourceRef(s.customerId(),s.sourceKey().substring(8))).toList();
|
|
|
|
|
var bookings=externalRefs.isEmpty() ? List.<AppointmentExternalSourceGateway.Source>of() : external.resolve(externalRefs);
|
|
|
|
|
var bookingMap=bookings.stream().collect(Collectors.toMap(s -> key(s.customerId(),"BOOKING:"+s.bookingNo()),s -> s));
|
|
|
|
|
var refs=new LinkedHashMap<SourceRef,AppointmentCoverageMapper.OrderRef>();
|
|
|
|
|
for (var source:requested) {
|
|
|
|
|
if (source.sourceKey().startsWith("ORDER:")) {
|
|
|
|
|
try {
|
|
|
|
|
long id=Long.parseLong(source.sourceKey().substring(6));
|
|
|
|
|
if (id>0) refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),id));
|
|
|
|
|
} catch (NumberFormatException ignored) { /* Invalid source is returned explicitly below. */ }
|
|
|
|
|
} else if (source.sourceKey().startsWith("BOOKING:")) {
|
|
|
|
|
var booking=bookingMap.get(key(source.customerId(),source.sourceKey()));
|
|
|
|
|
if (booking!=null && booking.orderId()!=null && Objects.equals(source.destinationKey(),booking.destinationKey())) {
|
|
|
|
|
refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),booking.orderId()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var unique=refs.values().stream().distinct().toList();
|
|
|
|
|
if (unique.size()!=refs.size()) throw new IllegalArgumentException("同一货物存在重复来源,请保留原外接登记来源后再核对");
|
|
|
|
|
var orders=unique.isEmpty() ? List.<AppointmentCoverageMapper.Source>of() : mapper.sources(unique);
|
|
|
|
|
var orderMap=orders.stream().collect(Collectors.toMap(s -> new AppointmentCoverageMapper.OrderRef(s.customerId(),s.orderId()),s -> s));
|
2026-10-10 10:38:06 +08:00
|
|
|
var facts=mapper.appointments(requested);
|
|
|
|
|
var bySource=facts.stream().collect(Collectors.groupingBy(f -> key(f.customerId(),f.sourceKey())));
|
2026-10-09 22:04:49 +08:00
|
|
|
var output=new ArrayList<Coverage>();
|
|
|
|
|
for (var source:requested) {
|
|
|
|
|
var booking=bookingMap.get(key(source.customerId(),source.sourceKey()));
|
|
|
|
|
if (booking!=null && !source.destinationKey().equals(booking.destinationKey())) {
|
|
|
|
|
output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(booking.recordVersion()),true)); continue;
|
|
|
|
|
}
|
|
|
|
|
if (booking!=null && !Set.of("CONFIRMED","RECEIVED","BOUND").contains(booking.bookingStatus()==null ? "" : booking.bookingStatus())) {
|
|
|
|
|
output.add(empty(source,"SOURCE_NOT_READY","CARRIER_CONFIRMATION",version(booking.recordVersion()),false)); continue;
|
|
|
|
|
}
|
|
|
|
|
var order=orderMap.get(refs.get(source));
|
2026-10-10 10:38:06 +08:00
|
|
|
var relevant=bySource.getOrDefault(key(source.customerId(),source.sourceKey()),List.of());
|
2026-10-09 22:04:49 +08:00
|
|
|
if (order==null) {
|
2026-10-10 10:38:06 +08:00
|
|
|
if(booking!=null && !relevant.isEmpty()) {
|
|
|
|
|
var planned=relevant.stream().map(f->new Appointment(f.appointmentNo(),null,f.status(),null,null,f.timezone())).distinct().toList();
|
|
|
|
|
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),"PENDING",planned,List.of("REAL_CARGO_MATERIALS","RECEIVING_CONFIRMATION"),version(booking.recordVersion()),false));continue;
|
|
|
|
|
}
|
2026-10-09 22:04:49 +08:00
|
|
|
output.add(empty(source,booking==null ? "SOURCE_NOT_FOUND" : "SOURCE_NOT_MATERIALIZED","REAL_CARGO_MATERIALS",booking==null ? null : version(booking.recordVersion()),false)); continue;
|
|
|
|
|
}
|
2026-10-10 10:38:06 +08:00
|
|
|
if(blank(order.destinationType()) || blank(order.fbaWarehouseCode())) {
|
|
|
|
|
output.add(empty(source,"SOURCE_NOT_READY","DESTINATION_UNVERIFIED",version(order.version()),false));continue;
|
|
|
|
|
}
|
2026-10-09 22:04:49 +08:00
|
|
|
if (!"FBA".equals(order.destinationType()) || !source.destinationKey().equals("FBA:"+order.fbaWarehouseCode())) {
|
|
|
|
|
output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue;
|
|
|
|
|
}
|
|
|
|
|
var distinct=new LinkedHashMap<String,Appointment>();
|
|
|
|
|
var seen=new HashSet<Long>(); long confirmed=0, covered=0; boolean invalid=false, changed=false;
|
|
|
|
|
for (var fact:relevant) {
|
2026-10-10 10:38:06 +08:00
|
|
|
if (fact.orderId()!=null && !Objects.equals(order.orderId(),fact.orderId()) || !Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.itemId())) { invalid=true; continue; }
|
2026-10-09 22:04:49 +08:00
|
|
|
covered+=fact.packages();
|
2026-10-10 10:38:06 +08:00
|
|
|
boolean success="SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null && !blank(fact.confirmationNo()) && !blank(fact.timezone());
|
|
|
|
|
if (success) confirmed+=fact.packages();
|
|
|
|
|
distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.confirmationNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,fact.timezone()));
|
|
|
|
|
String currentVersion=(booking==null?"":"BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version());
|
|
|
|
|
changed|=!Objects.equals(currentVersion,fact.sourceVersion());
|
2026-10-09 22:04:49 +08:00
|
|
|
}
|
|
|
|
|
var missing=new ArrayList<String>();
|
|
|
|
|
String status;
|
|
|
|
|
if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); }
|
|
|
|
|
else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); }
|
|
|
|
|
else if (covered<order.packages()) { status="PARTIAL"; missing.add("UNCOVERED_CARGO"); }
|
|
|
|
|
else if (confirmed<order.packages()) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
|
|
|
|
|
else { status="CONFIRMED"; }
|
2026-10-10 10:38:06 +08:00
|
|
|
if (relevant.stream().anyMatch(f->"SUCCESS".equals(f.status()) && blank(f.timezone()))) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED");
|
2026-10-09 22:04:49 +08:00
|
|
|
if (order.shipmentReferencesMissing()) missing.add("FBA_SHIPMENT_REFERENCES");
|
|
|
|
|
if (changed) missing.add("SOURCE_CHANGED_REVIEW");
|
|
|
|
|
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing),
|
|
|
|
|
(booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid));
|
|
|
|
|
}
|
|
|
|
|
return List.copyOf(output);
|
|
|
|
|
}
|
|
|
|
|
private static Coverage empty(SourceRef s,String status,String missing,String version,boolean review) {
|
|
|
|
|
return new Coverage(s.customerId(),s.sourceKey(),s.destinationKey(),status,List.of(),List.of(missing),version,review);
|
|
|
|
|
}
|
|
|
|
|
private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; }
|
|
|
|
|
private static String version(Number version) { return version==null ? null : version.toString(); }
|
|
|
|
|
private static boolean blank(String value) { return value==null || value.isBlank(); }
|
|
|
|
|
}
|