feat(appointments): support scoped receiving and stable trip continuation

This commit is contained in:
2026-10-16 15:43:04 +08:00
parent 4e2ea89403
commit 30f48bd926
84 changed files with 4879 additions and 136 deletions
@@ -63,7 +63,9 @@ public class AppointmentWorkCommandService {
fields(payload,Set.of("reviewVersion","note"),Set.of("reviewVersion","note"));
int version=integer(payload,"reviewVersion");
if(!state.isReviewRequired() || state.getReviewVersion()!=version) throw conflict("影响版本已变化,请重新核对");
if(changed(state,fact)) throw conflict("原业务已有更新,须先同步最新影响");
if(changed(state,fact) || fact!=null && state.getObservedImpactToken()!=null
&& !Objects.equals(state.getObservedImpactToken(),fact.impactToken())) throw conflict("原业务已有更新,须先同步最新影响");
requireCurrentImpacts(state);
if(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排");
summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null);
}
@@ -114,6 +116,20 @@ public class AppointmentWorkCommandService {
store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result);
return result;
}
private void requireCurrentImpacts(AppointmentWorkState state) {
store.requireInitialized();
var prerequisites=AppointmentWorkSyncService.impactPrerequisites(state,store.all());
if(prerequisites.isEmpty()) return;
var current=gateway.facts(prerequisites);
for(var prior:prerequisites) {
if(prior.getType()==Type.ROUTE) continue;
var fact=current.get(prior.getWorkItemKey());
if(fact==null) throw unavailable("关联安排权威事实缺失,不能完成复核");
if(prior.isObservedArranged() && prior.getObservedDependentImpactToken()!=null
&& !Objects.equals(prior.getObservedDependentImpactToken(),fact.dependentImpactToken()))
throw conflict("关联安排已有新变化,须先同步最新影响再复核");
}
}
private List<String> markDownstream(AppointmentWorkState source,String reason,Actor actor) {
var states=store.all(); var keys=new LinkedHashSet<String>(); AppointmentWorkSyncService.expandDependents(source.getWorkItemKey(),states,keys); keys.remove(source.getWorkItemKey());
var candidates=states.stream().filter(s->keys.contains(s.getWorkItemKey()) && !s.isRetired()).toList(); var facts=gateway.facts(candidates); var affected=new ArrayList<String>();
@@ -33,6 +33,10 @@ public class AppointmentWorkController {
@RequestParam(required=false) String containerBookingId,@RequestParam(required=false) Bucket bucket,@RequestParam(required=false) String keyword) {
return ApiResponse.success(queries.items(view,page,size,date,flow,types,containerBookingId,bucket,keyword,actor(user)));
}
@GetMapping("/items/resolve")
public ApiResponse<BatchWorkResolution> resolve(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String batchKey,@RequestParam(required=false) String customerId) {
return ApiResponse.success(queries.resolveBatch(batchKey,customerId,actor(user)));
}
@GetMapping("/items/{workItemKey}")
public ApiResponse<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
return ApiResponse.success(queries.detail(workItemKey,actor(user)));
@@ -36,7 +36,7 @@ public class AppointmentWorkDirectTransportProvider implements BusinessProvider
done?ExecutionStatus.COMPLETED:carrier.executionStatus()==ExecutionStatus.COMPLETED?ExecutionStatus.IN_PROGRESS:ExecutionStatus.PENDING,
carrier.target(),carrier.confirmed(),done?delivery.actual():null,done?delivery.timezone():carrier.timezone(),delivery.businessNo(),null,
List.copyOf(read),action==null?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.actionPermissions():carrier.actionPermissions(),action,
done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility());
done?List.of():carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.blockReasons():carrier.blockReasons(),List.copyOf(missing),List.copyOf(refs),carrier.responsibility(),null);
}).toList();
}
private FactRequest request(FactRequest r,Type type,String suffix) { return new FactRequest(r.workItemKey()+suffix,type,r.flow(),r.containerBookingId(),r.requirementKey(),r.batchKey(),null,r.batchKeys(),r.containers(),r.sources()); }
@@ -8,9 +8,15 @@ public interface AppointmentWorkDispatchPort {
record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId,
@NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { }
record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { }
record BatchSource(String sourceKey,String destinationKey,int packageCount) { }
record BatchReference(String batchKey,String batchNo,String customerId,List<BatchSource> sources) { }
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
void bind(String customerId,long dispatchId,List<PhysicalItem> items,List<Allocation> allocations);
void requireReplay(String customerId,long dispatchId,List<Allocation> allocations);
void requireDeparture(String dispatchNo);
/** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */
String departureBlockReason(String dispatchNo);
void removeItem(long dispatchId,long itemId);
void requireTripBinding(String dispatchNo,long tripId);
}
@@ -77,12 +77,16 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
}
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
var expected=new HashSet<>(requests.stream().map(FactRequest::workItemKey).toList());
var requestByKey=new HashMap<String,FactRequest>();requests.forEach(r->requestByKey.put(r.workItemKey(),r));
var expected=new HashSet<>(requestByKey.keySet());
for(var fact:facts) {
if(fact==null || !expected.remove(fact.workItemKey()) || fact.fingerprint()==null || fact.fingerprint().isBlank() || fact.impactToken()==null || fact.impactToken().isBlank() || fact.planStatus()==null || fact.executionStatus()==null
|| fact.readPermissions()==null || fact.readPermissions().isEmpty() || fact.actionPermissions()==null || fact.blockReasons()==null || fact.missingFields()==null || fact.references()==null)
throw unavailable("工作事实缺少身份、版本或资格");
if(fact.executionStatus()==ExecutionStatus.COMPLETED && fact.actual()==null) throw unavailable("完成事实缺少实际时间");
var request=requestByKey.get(fact.workItemKey());
if(request.type()==Type.TRANSPORT && request.flow()==Flow.TRANSFER && (fact.transportPlanImpactToken()==null || fact.transportPlanImpactToken().isBlank()))
throw unavailable("共享车次事实缺少独立计划影响版本");
if(fact.actual()!=null) validateActual(fact.actual());
if(!fact.planExists() && (fact.target()!=null || fact.confirmed()!=null || fact.planStatus()==PlanStatus.CONFIRMED)) throw unavailable("原计划存在性与计划结果冲突");
validateSourceSlot(fact.target()); validateSourceSlot(fact.confirmed());
@@ -20,8 +20,10 @@ public interface AppointmentWorkFactGateway {
ExecutionStatus executionStatus, Slot target, Slot confirmed, Actual actual, String timezone,
String businessNo, String tripKey, List<String> readPermissions, List<String> actionPermissions,
Action action, List<String> blockReasons, List<String> missingFields, List<BusinessReference> references,
Responsibility responsibility) {
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null; }
Responsibility responsibility, String transportPlanImpactToken) {
/** Shared fleet transport separates its own cargo impact from changes affecting receiving appointments. */
public String dependentImpactToken() { return transportPlanImpactToken==null?impactToken:transportPlanImpactToken; }
public boolean arranged() { return planStatus == PlanStatus.CONFIRMED || planStatus == PlanStatus.NOT_REQUIRED || target != null || confirmed != null; }
}
SourcePage discover(String after, int size);
ContainerSource container(String containerBookingId);
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** A future work key may receive an audited follow-up seed before source discovery creates its work row. */
@Component
public class AppointmentWorkFollowupInheritance {
public record Member(String batchKey,String customerId) { }
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway.OwnerDirectory directory;
public AppointmentWorkFollowupInheritance(AppointmentWorkStore store,AppointmentWorkFactGateway.OwnerDirectory directory) {
this.store=store;this.directory=directory;
}
public AppointmentWorkStore.Followup resolve(String ownerId,LocalDate nextFollowupDate) {
if(ownerId==null || ownerId.isBlank()) throw bad("请选择有效平台负责人");
var owner=directory.activePlatformOwner(ownerId);
if(owner==null || !ownerId.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw bad("负责人不是有效平台操作人员");
return new AppointmentWorkStore.Followup(owner,nextFollowupDate);
}
@Transactional(propagation=Propagation.MANDATORY)
public void seed(Member original,Member later,AppointmentWorkStore.Followup followup,String operatorId) {
key(original);key(later);
if(!original.customerId().equals(later.customerId()) || original.batchKey().equals(later.batchKey())) throw bad("跟进继承必须来自同一客户的原批次和新批次");
if(followup==null || followup.owner()==null || followup.owner().id()==null || followup.owner().id().isBlank() || followup.owner().name()==null || followup.owner().name().isBlank()) throw bad("后送跟进缺少真实负责人");
store.lock();write(later,followup,operatorId);
}
@Transactional(propagation=Propagation.MANDATORY)
public void inherit(List<Member> originals,List<Member> successors,String operatorId) {
validate(originals);validate(successors);store.lock();
var values=new HashMap<String,Set<AppointmentWorkStore.Followup>>();
for(var original:originals) {
var key=key(original);var state=store.find(key);
var current=state==null?store.inheritedFollowup(key):new AppointmentWorkStore.Followup(state.getOwner(),state.getFollowupDate());
if(current!=null) values.computeIfAbsent(original.customerId(),ignored->new HashSet<>()).add(current);
}
// Validate every target before writing; caller transaction still supplies rollback for all original-domain writes.
for(var successor:successors) if(values.getOrDefault(successor.customerId(),Set.of()).size()>1)
throw conflict("合批来源的负责人或跟进日期不同,请先在原工作事项统一后再合批");
for(var successor:successors) {
var current=values.getOrDefault(successor.customerId(),Set.of());
if(!current.isEmpty()) write(successor,current.iterator().next(),operatorId);
}
}
private void write(Member target,AppointmentWorkStore.Followup followup,String operatorId) {
if(operatorId==null || operatorId.isBlank()) throw bad("跟进继承缺少真实操作人");
var key=key(target);var state=store.find(key);var previous=store.inheritedFollowup(key);
if(state!=null) {
if(Objects.equals(state.getOwner(),followup.owner()) && Objects.equals(state.getFollowupDate(),followup.nextFollowupDate())) return;
throw conflict("目标工作已存在独立跟进,不能用继承覆盖");
}
if(previous!=null) {
if(previous.equals(followup)) return;
throw conflict("目标批次已有不同的跟进继承记录");
}
var future=new AppointmentWorkState();future.setWorkItemKey(key);
store.history(future,new Actor(operatorId,null,Set.of()),"FOLLOWUP_INHERITED","继承原送仓事项的负责人及跟进日期",null,null,followup);
}
private static String key(Member member) {
if(member==null || member.batchKey()==null || !member.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*") || member.customerId()==null || member.customerId().isBlank()) throw bad("跟进继承缺少真实批次客户范围");
return AppointmentWorkPolicy.key("BATCH:"+member.batchKey(),Type.DELIVERY,customerRequirement(member.batchKey(),member.customerId()));
}
private static void validate(List<Member> members) {
if(members==null || members.isEmpty() || members.size()>2000 || new HashSet<>(members).size()!=members.size()) throw bad("跟进继承范围须为1至2000个不重复客户批次");
members.forEach(AppointmentWorkFollowupInheritance::key);
}
}
@@ -66,4 +66,6 @@ public interface AppointmentWorkMapper {
VALUES(#{id},#{workItemKey},#{operatorId},#{operatorName},#{action},#{summary},#{commandKey},#{requestHash},#{responseJson},#{occurredAt})
""") int insertHistory(History row);
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} ORDER BY occurred_at DESC,id DESC LIMIT 1001") List<History> selectHistory(@Param("key") String key);
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND action='FOLLOWUP_INHERITED' ORDER BY occurred_at DESC,id DESC LIMIT 1")
History inheritedFollowup(String key);
}
@@ -51,6 +51,8 @@ public final class AppointmentWorkModels {
public record HistoryEntry(Instant occurredAt, String operatorName, String action, String summary) { }
public record WorkDetail(WorkItem item, List<WorkItem> relatedItems,
List<BusinessReference> references, List<HistoryEntry> history, Review review, RequirementDetails requirement, ReturnHandoff returnHandoff) { }
public record BatchWorkCandidate(String customerId,String workItemKey,String batchNo) { }
public record BatchWorkResolution(String batchKey,String customerId,WorkDetail detail,List<BatchWorkCandidate> candidates) { }
public record Review(int version,List<String> reasons) { }
public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { }
public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL }
@@ -14,6 +14,15 @@ public final class AppointmentWorkPolicy {
public static final ZoneId REMINDER_ZONE=ZoneId.of("America/Los_Angeles");
public static final String ORDER_READ="platform:overseas-warehouse:last-mile-order:list";
private AppointmentWorkPolicy() { }
public static String customerRequirement(String batchKey,String customerId) {
if(batchKey==null || customerId==null || customerId.isBlank()) throw unavailable("送仓需求缺少批次或客户身份");
return batchKey+"|CUSTOMER:"+customerId;
}
public static String deliveryCustomer(List<SourceReference> sources) {
var customers=sources.stream().map(SourceReference::customerId).distinct().toList();
if(customers.size()!=1 || customers.get(0)==null || customers.get(0).isBlank()) throw unavailable("送仓事项须明确唯一客户货物范围");
return customers.get(0);
}
public static String key(String containerId,Type type,String requirementKey) {
return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32);
}
@@ -89,6 +98,7 @@ public final class AppointmentWorkPolicy {
var today=LocalDate.now(clock.withZone(timezone==null?REMINDER_ZONE:zone(timezone)));
if(timezone==null) missing.add("跟进日期按业务默认时区America/Los_Angeles判断;作业时区待核实");
if(state.getOwner()==null && execution!=ExecutionStatus.COMPLETED) missing.add("负责人待分配");
if(state.getFollowupDate()==null && execution!=ExecutionStatus.COMPLETED && !state.isRetired()) missing.add("下次跟进日期待安排");
var overdue=new ArrayList<String>();
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过");
@@ -49,7 +49,21 @@ public class AppointmentWorkQueryService {
.map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList());
}
private Action next(WorkItem item,Map<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> visited) {
if(!visited.add(item.workItemKey()) || item.blockReasons().isEmpty() || item.reviewRequired()) return item.nextAction();
if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction();
if(item.type()==Type.DELIVERY && item.executionStatus()!=ExecutionStatus.COMPLETED && item.blockReasons().isEmpty()
&& (item.planStatus()==PlanStatus.CONFIRMED || item.planStatus()==PlanStatus.NOT_REQUIRED)) {
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
var transport=items.get(key);
if(transport==null || transport.type()!=Type.TRANSPORT || transport.executionStatus()==ExecutionStatus.COMPLETED) continue;
var action=transport.nextAction();
if(action==null) continue;
if(action.code()==ActionCode.RESOLVE_REVIEW)
return new Action(action.code(),"先复核车辆安排",transport.workItemKey(),action.target());
if(action.code()==ActionCode.ARRANGE_TRANSPORT && action.target() instanceof TripTarget target)
return new Action(action.code(),target.tripId()==null?"继续安排本批车辆":"核对本车发车安排",transport.workItemKey(),target);
}
}
if(item.blockReasons().isEmpty()) return item.nextAction();
for(var key:effectiveKeys(states.get(item.workItemKey()).getPrerequisites(),states)) {
var prior=items.get(key);
if(prior!=null && prior.executionStatus()!=ExecutionStatus.COMPLETED && (prior.reviewRequired() || prior.planStatus()==PlanStatus.UNPLANNED || prior.planStatus()==PlanStatus.AWAITING_REPLY)) {
@@ -87,6 +101,22 @@ public class AppointmentWorkQueryService {
long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size());
return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size);
}
public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) {
if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键");
if(customerId!=null && customerId.isBlank()) throw bad("客户身份不能为空");
if(!Objects.equals(store.requireInitialized(),facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
var matches=store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.DELIVERY && batchKey.equals(s.getBatchKey()))
.filter(s->customerId==null || customerId.equals(deliveryCustomer(s.getSources()))).toList();
if(matches.isEmpty()) throw missing("当前批次的送仓事项尚不存在,请先同步当前批次");
var current=facts.facts(matches);
var visible=matches.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
if(visible.isEmpty()) throw forbidden("无权读取关联来源");
var candidates=visible.stream().map(s->new BatchWorkCandidate(deliveryCustomer(s.getSources()),s.getWorkItemKey(),s.getBatchNo())).sorted(Comparator.comparing(BatchWorkCandidate::customerId)).toList();
if(candidates.stream().map(BatchWorkCandidate::customerId).distinct().count()!=candidates.size()) throw conflict("同一批次客户存在重复送仓事项");
if(candidates.size()>1) return new BatchWorkResolution(batchKey,null,null,candidates);
var selected=candidates.get(0);
return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates);
}
public WorkDetail detail(String key,Actor actor) {
var snapshot=snapshot(actor); var state=store.get(key);
var fact=snapshot.facts().get(key);
@@ -36,6 +36,7 @@ public class AppointmentWorkState {
private String reviewReason;
private String observedFingerprint;
private String observedImpactToken;
private String observedDependentImpactToken;
private boolean observedArranged;
private Responsibility responsibility;
private ReturnHandoff returnHandoff;
@@ -32,6 +32,11 @@ public class AppointmentWorkStore {
public AppointmentWorkState get(String key) {
var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row);
}
public AppointmentWorkState find(String key) { var row=mapper.get(key);return row==null?null:decode(row); }
public record Followup(Owner owner,LocalDate nextFollowupDate) { }
public Followup inheritedFollowup(String key) {
var row=mapper.inheritedFollowup(key);return row==null?null:read(row.getResponseJson(),Followup.class);
}
public void insert(AppointmentWorkState state) { mapper.insert(row(state)); }
public void save(AppointmentWorkState state) {
state.setVersion(state.getVersion()+1);
@@ -69,7 +69,10 @@ public class AppointmentWorkSyncService {
for(var requirement:source.requirements()) {
if(requirement==null || requirement.requirementKey()==null || requirement.requirementKey().isBlank() || !seen.add(requirement.requirementKey()) || requirement.references()==null || requirement.replacesRequirementKeys()==null || requirement.sources()==null)
throw unavailable("送货需求缺少稳定身份或重复");
if(requirement.batchKey()!=null && !batches.add(requirement.batchKey())) throw unavailable("同柜批次需求重复,必须由原域归并来源");
if(requirement.batchKey()!=null) {
var identity=requirement.batchKey().startsWith("FBA_APPOINTMENT:")?customerRequirement(requirement.batchKey(),deliveryCustomer(requirement.sources())):requirement.batchKey();
if(!batches.add(identity)) throw unavailable("同柜批次客户需求重复,必须由原域归并来源");
}
if(requirement.timezone()!=null) zone(requirement.timezone());
}
}
@@ -91,10 +94,17 @@ public class AppointmentWorkSyncService {
for(var state:created) {
state.setOwner(actor.id().equals(SYSTEM.id())?null:new Owner(actor.id(),actor.name()));
state.setFollowupDate(LocalDate.now(state.getTimezone()==null?REMINDER_ZONE:zone(state.getTimezone())));
var inherited=store.inheritedFollowup(state.getWorkItemKey());
if(inherited!=null) { state.setOwner(inherited.owner());state.setFollowupDate(inherited.nextFollowupDate()); }
String pickup=key(source.containerBookingId(),Type.PICKUP,null),receipt=key(source.containerBookingId(),Type.WAREHOUSE_RECEIPT,null);
if(state.getType()==Type.WAREHOUSE_RECEIPT) state.setPrerequisites(List.of(pickup));
if(state.getType()==Type.DELIVERY) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.TRANSPORT,state.getRequirementKey(),state.getBatchKey())));
if(state.getType()==Type.TRANSPORT) state.setPrerequisites(List.of(flow==Flow.TRANSFER?receipt:pickup,requirementKey(source.containerBookingId(),Type.DELIVERY,state.getRequirementKey(),state.getBatchKey())));
if(state.getType()==Type.TRANSPORT) {
var prerequisites=new LinkedHashSet<String>();prerequisites.add(flow==Flow.TRANSFER?receipt:pickup);
for(var r:requirements) if(Objects.equals(state.getBatchKey(),r.batchKey()) && (r.batchKey()!=null || Objects.equals(state.getRequirementKey(),r.requirementKey())))
prerequisites.add(requirementKey(source.containerBookingId(),Type.DELIVERY,r.requirementKey(),r.batchKey()));
state.setPrerequisites(List.copyOf(prerequisites));
}
if(state.getType()==Type.EMPTY_RETURN) {
String containerId=source.containerBookingId();
state.setPrerequisites(flow==Flow.TRANSFER?List.of(receipt):source.requirements().stream().map(r->requirementKey(containerId,Type.DELIVERY,r.requirementKey(),r.batchKey())).toList());
@@ -102,6 +112,18 @@ public class AppointmentWorkSyncService {
store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null);
}
String generationContainerId=source.containerBookingId();
// Rebuild each batch vehicle's customer edges from the complete current manifest.
// A shared vehicle retains every customer delivery; no last customer wins the edge.
for(var batch:requirements.stream().map(Requirement::batchKey).filter(Objects::nonNull).distinct().toList()) {
var vehicle=known.get(requirementKey(generationContainerId,Type.TRANSPORT,null,batch));
var dependencies=new LinkedHashSet<>(vehicle.getPrerequisites());
dependencies.removeIf(k->{var s=known.get(k);return s!=null && s.getType()==Type.DELIVERY && batch.equals(s.getBatchKey());});
requirements.stream().filter(r->batch.equals(r.batchKey())).map(r->requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),batch)).forEach(dependencies::add);
var sources=requirements.stream().filter(r->batch.equals(r.batchKey())).flatMap(r->r.sources().stream()).distinct().toList();
if(!vehicle.getPrerequisites().equals(List.copyOf(dependencies)) || !vehicle.getSources().equals(sources)) {
vehicle.setPrerequisites(List.copyOf(dependencies));vehicle.setSources(sources);store.save(vehicle);
}
}
var activeKeys=new HashSet<String>(); requirements.forEach(r->{ activeKeys.add(requirementKey(generationContainerId,Type.DELIVERY,r.requirementKey(),r.batchKey())); activeKeys.add(requirementKey(generationContainerId,Type.TRANSPORT,r.requirementKey(),r.batchKey())); });
for(var old:existing) if(old.getRequirementKey()!=null && !"BATCH_REPLACED".equals(old.getRetirementReason()) && !activeKeys.contains(old.getWorkItemKey())) {
String containerId=source.containerBookingId();
@@ -116,6 +138,7 @@ public class AppointmentWorkSyncService {
store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue;
}
var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey()))
.filter(r->old.getType()!=Type.DELIVERY || old.getSources().isEmpty() || r.sources().isEmpty() || r.sources().stream().anyMatch(s->old.getSources().stream().anyMatch(o->Objects.equals(o.customerId(),s.customerId()))))
.map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList();
// Removed dispatch items no longer exist in the original allocation table. Its full current
// container manifest restores the unallocated demand or names the newly assigned dispatch.
@@ -126,7 +149,9 @@ public class AppointmentWorkSyncService {
}
return created.stream().map(AppointmentWorkState::getWorkItemKey).toList();
}
private static String requirementKey(String container,Type type,String requirement,String batch) { return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,null); }
private static String requirementKey(String container,Type type,String requirement,String batch) {
return batch==null?key(container,type,requirement):key("BATCH:"+batch,type,type==Type.DELIVERY && batch.startsWith("FBA_APPOINTMENT:")?requirement:null);
}
private void add(ContainerSource source,Type type,Requirement requirement,Flow flow,Map<String,AppointmentWorkState> known,List<AppointmentWorkState> created) {
var state=create(source,type,requirement,flow); var old=known.get(state.getWorkItemKey());
if(old==null) { created.add(state); known.put(state.getWorkItemKey(),state); }
@@ -147,14 +172,14 @@ public class AppointmentWorkSyncService {
}
old.setBatchNo(requirement.batchNo()); old.setOriginName(requirement.originName()); old.setDestinationName(requirement.destinationName()); old.setDestinationKind(requirement.destinationKind()); old.setReferences(requirement.references()); old.setSources(requirement.sources());
if(type==Type.DELIVERY) old.setTimezone(requirement.timezone());
if(update) store.save(old);
if(update && !created.contains(old)) store.save(old);
}
}
private AppointmentWorkState create(ContainerSource source,Type type,Requirement requirement,Flow flow) {
var state=new AppointmentWorkState(); state.setWorkItemKey(requirement==null?key(source.containerBookingId(),type,null):requirementKey(source.containerBookingId(),type,requirement.requirementKey(),requirement.batchKey()));
state.setType(type); state.setFlow(flow); state.setContainerBookingId(source.containerBookingId()); state.setContainerNo(source.containerNo()); state.setSourceVersion(source.sourceVersion());
state.setTimezone(type==Type.ROUTE?source.timezone():null); state.setContainers(List.of(new ContainerReference(source.containerBookingId(),null,source.containerNo())));
if(requirement!=null) { state.setRequirementKey(requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind());
if(requirement!=null) { state.setRequirementKey(type==Type.TRANSPORT && requirement.batchKey()!=null?requirement.batchKey():requirement.requirementKey()); state.setBatchKey(requirement.batchKey()); state.setBatchNo(requirement.batchNo()); state.setDestinationKind(requirement.destinationKind());
state.setOriginName(requirement.originName()); state.setDestinationName(requirement.destinationName()); state.setTimezone(type==Type.DELIVERY?requirement.timezone():null); state.setReferences(requirement.references()); state.setSources(requirement.sources());
if(requirement.batchKey()!=null) state.setBatchKeys(List.of(requirement.batchKey())); }
return state;
@@ -166,17 +191,18 @@ public class AppointmentWorkSyncService {
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
if(fact!=null && state.getObservedImpactToken()!=null && !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) && state.isObservedArranged()) {
affected.add(state.getWorkItemKey());
expandDependents(state.getWorkItemKey(),states,affected);
if(state.getObservedDependentImpactToken()!=null && !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken()))
expandDependents(state.getWorkItemKey(),states,affected);
}
}
for(var state:states) {
if(state.isRetired()) continue;
var fact=facts.get(state.getWorkItemKey()); if(fact==null) continue;
boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null;
boolean changed=changed(state,fact) || !Objects.equals(state.getObservedImpactToken(),fact.impactToken()) || !Objects.equals(state.getObservedDependentImpactToken(),fact.dependentImpactToken()) || state.isObservedArranged()!=fact.arranged() || !state.getReadPermissions().equals(fact.readPermissions()),baseline=state.getObservedFingerprint()==null;
boolean review=affected.contains(state.getWorkItemKey()) && arranged(state,fact) && !completed(fact);
if(review) { state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason("原业务时间、货量或执行关联发生变化,请核对已有安排"); }
if(changed || baseline || review) {
state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state);
state.setObservedFingerprint(fact.fingerprint()); state.setObservedImpactToken(fact.impactToken()); state.setObservedDependentImpactToken(fact.dependentImpactToken()); state.setObservedArranged(fact.arranged()); state.setReadPermissions(List.copyOf(fact.readPermissions())); store.save(state);
store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null);
}
}
@@ -185,9 +211,29 @@ public class AppointmentWorkSyncService {
var queue=new ArrayDeque<String>(); queue.add(key); var seen=new HashSet<String>();
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
while(!queue.isEmpty()) { var current=queue.remove(); if(!seen.add(current)) continue;
// A changed customer batch requires its shared vehicle to be checked. That does not
// make the vehicle's other customers' appointments change. A change originating in
// the vehicle itself still reaches all of its current dependent appointments.
var currentState=byKey.get(current);
if(!current.equals(key) && currentState!=null && currentState.getType()==Type.TRANSPORT) continue;
for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey());
}
}
/** Reverse of the impact traversal: a shared vehicle is an authority, not a sibling bridge. */
static List<AppointmentWorkState> impactPrerequisites(AppointmentWorkState target,List<AppointmentWorkState> states) {
var byKey=states.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
var pending=new ArrayDeque<>(effectiveKeys(target.getPrerequisites(),byKey));
var seen=new HashSet<String>(); seen.add(target.getWorkItemKey());
var result=new ArrayList<AppointmentWorkState>();
while(!pending.isEmpty()) {
var key=pending.remove(); if(!seen.add(key)) continue;
var state=byKey.get(key);
if(state==null || state.isRetired()) throw conflict("关联安排已变化,须先同步当前工作关系");
result.add(state);
if(state.getType()!=Type.TRANSPORT) pending.addAll(effectiveKeys(state.getPrerequisites(),byKey));
}
return List.copyOf(result);
}
private void reconcileTransport(List<AppointmentWorkState> all,Map<String,Fact> facts) {
var members=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && facts.containsKey(s.getWorkItemKey()) && facts.get(s.getWorkItemKey()).tripKey()!=null)
.collect(Collectors.groupingBy(s->facts.get(s.getWorkItemKey()).tripKey()));