feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** Original business commands invalidate the manifest atomically with their business write. */
@Component
public class AppointmentWorkChanges {
private final AppointmentWorkMapper mapper;
public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; }
@Transactional(propagation=Propagation.MANDATORY)
public void requirementsChanged() {
if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化");
}
}
@@ -0,0 +1,161 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
public class AppointmentWorkCommandService {
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway gateway;
private final AppointmentWorkSyncService sync;
private final AppointmentWorkQueryService queries;
public AppointmentWorkCommandService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway,AppointmentWorkSyncService sync,AppointmentWorkQueryService queries) {
this.store=store; this.gateway=gateway; this.sync=sync; this.queries=queries;
}
@Transactional
public CommandResult execute(String key,CommandRequest request,Actor actor) {
if(request==null || request.command()==null || request.expectedVersion()<0 || request.idempotencyKey()==null || request.idempotencyKey().isBlank() || request.idempotencyKey().length()>64 || request.payload()==null || !request.payload().isObject()) throw bad("工作命令参数无效");
String permission=switch(request.command()) { case SAVE_FOLLOWUP,RESOLVE_REVIEW->FOLLOWUP; default->MANAGE; };
if(!actor.has(LIST) || !actor.has(permission)) throw forbidden("缺少工作操作权限");
store.lock();
var state=store.get(key);
var hash=digest(actor.id()+"\n"+request.command()+"\n"+request.expectedVersion()+"\n"+canonical(request.payload()));
var replay=store.replay(key,request.idempotencyKey());
if(replay!=null) {
if(!Objects.equals(actor.id(),replay.getOperatorId()) || !Objects.equals(hash,replay.getRequestHash())) throw conflict("同一请求编号的操作者或内容不一致");
if(state.getReadPermissions().isEmpty() || !state.getReadPermissions().stream().allMatch(actor::has)) throw forbidden("关联来源读取权限已失效");
return store.read(replay.getResponseJson(),CommandResult.class);
}
var current=gateway.facts(state.getType()==Type.ROUTE?List.of():List.of(state)); var fact=current.get(key);
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联业务来源");
state.setReadPermissions(state.getType()==Type.ROUTE?List.of(ORDER_READ):List.copyOf(fact.readPermissions()));
if(state.getVersion()!=request.expectedVersion()) throw conflict("事项已更新,请刷新后保留输入重新提交");
if(state.isRetired() || completed(fact)) throw conflict("该事项已完成或已替代,只能查看历史");
var payload=request.payload(); var affected=new LinkedHashSet<String>(); String summary;
switch(request.command()) {
case CONFIRM_ROUTE -> {
fields(payload,Set.of("flow","reason"),Set.of("flow"));
if(state.getType()!=Type.ROUTE) throw bad("只能在路线需求确认运输方式");
Flow flow; try { flow=Flow.valueOf(text(payload,"flow",true,16)); } catch(IllegalArgumentException e) { throw bad("运输路线无效"); }
if(flow==Flow.UNKNOWN) throw bad("请选择中转或直送");
var source=gateway.container(state.getContainerBookingId()); AppointmentWorkSyncService.validateSource(source);
if(!Objects.equals(state.getSourceVersion(),source.sourceVersion())) throw conflict("柜次来源版本变化,请先同步核对");
if(source.establishedFlow()!=Flow.UNKNOWN && source.establishedFlow()!=flow) throw conflict("选择路线与原业务既定路线不一致");
var existing=store.container(state.getContainerBookingId());
if(existing.stream().anyMatch(s->s.getType()!=Type.ROUTE)) throw conflict("该柜已生成路线工作,不能切换抹去已有安排");
affected.addAll(sync.generate(source,flow,existing,actor)); state.setFlow(flow); state.setRetired(true); state.setRetirementReason("ROUTE_CONFIRMED"); state.setReplacementKeys(List.copyOf(affected));
summary="确认"+(flow==Flow.TRANSFER?"海外仓中转":"整柜直送")+";"+Objects.toString(text(payload,"reason",false,1000),"");
}
case SAVE_FOLLOWUP -> {
fields(payload,Set.of("ownerId","followupDate","note"),Set.of("ownerId","followupDate","note"));
state.setOwner(gateway.owner(text(payload,"ownerId",true,64),actor));
state.setFollowupDate(date(payload.get("followupDate")));
summary=text(payload,"note",true,1000);
// This command never clears pending acceptance, review, or original work requirements.
}
case RESOLVE_REVIEW -> {
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(fact!=null && !fact.blockReasons().isEmpty()) throw conflict("原业务仍有阻断,请先处理原安排");
summary=text(payload,"note",true,1000); state.setReviewRequired(false); state.setReviewReason(null);
}
case SAVE_REQUIREMENT -> {
fields(payload,Set.of("target","bookingRequired","receivingRequirements","reason"),Set.of("target","bookingRequired","receivingRequirements","reason"));
if(!requirementOwned(state,fact)) throw conflict("该计划由原业务维护,请从原安排入口处理");
if(!payload.get("bookingRequired").isBoolean()) throw bad("预约要求须为布尔值");
boolean bookingRequired=payload.get("bookingRequired").asBoolean();
String requirements=text(payload,"receivingRequirements",false,1000),reason=text(payload,"reason",true,1000);
if(!bookingRequired && (state.getType()!=Type.DELIVERY || !"CUSTOMER".equals(state.getDestinationKind()) || requirements==null)) throw bad("仅客户仓可凭明确收货要求登记无需预约");
Slot target=slot(payload.get("target")); validateSlot(target);
String authorityZone=fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone();
if(target!=null && !Objects.equals(authorityZone,target.timezone())) throw conflict("作业地点时区尚未核实或已变化");
boolean hadPlan=arranged(state,fact);
boolean change=!Objects.equals(target,state.getTarget()) || state.isBookingRequired()!=bookingRequired || !Objects.equals(requirements,state.getReceivingRequirements());
state.setTarget(target); state.setBookingRequired(bookingRequired); state.setReceivingRequirements(requirements); state.setRequirementReason(reason);
if(hadPlan && change) affected.addAll(markDownstream(state,reason,actor));
summary=reason;
}
case HANDOFF_RETURN -> {
fields(payload,Set.of("recipient","reason"),Set.of("recipient","reason")); requireReturn(state);
var recipient=responsibility(payload.get("recipient"),actor);
String reason=text(payload,"reason",true,1000);
var previous=state.getResponsibility();
if(previous==null && fact!=null) previous=fact.responsibility();
if(previous==null) previous=new Responsibility(ResponsibilityKind.EXTERNAL,null,"原责任方待核实");
int version=state.getReturnHandoff()==null?1:state.getReturnHandoff().version()+1;
state.setReturnHandoff(new ReturnHandoff(version,previous,recipient,HandoffStatus.PENDING_ACCEPTANCE,reason));
if(state.getOwner()==null) state.setOwner(new Owner(actor.id(),actor.name()));
var localZone=queries.current(state,fact,actor).timezone();
state.setFollowupDate(LocalDate.now(localZone==null?REMINDER_ZONE:zone(localZone)));
summary="还空责任转交:"+previous.name()+" → "+recipient.name()+";待接手;"+reason;
}
case ACCEPT_RETURN -> {
fields(payload,Set.of("handoffVersion","note"),Set.of("handoffVersion","note")); requireReturn(state);
var handoff=state.getReturnHandoff();
if(handoff==null || handoff.status()!=HandoffStatus.PENDING_ACCEPTANCE || handoff.version()!=integer(payload,"handoffVersion")) throw conflict("还空转交版本变化或已经接手");
if(handoff.to().kind()==ResponsibilityKind.USER && !actor.id().equals(handoff.to().id())) throw forbidden("仅当前指定接手人可确认接手");
String note=text(payload,"note",handoff.to().kind()!=ResponsibilityKind.USER,1000);
state.setResponsibility(handoff.to()); state.setReturnHandoff(new ReturnHandoff(handoff.version(),handoff.from(),handoff.to(),HandoffStatus.ACCEPTED,handoff.reason()));
if(handoff.to().kind()==ResponsibilityKind.USER) state.setOwner(new Owner(actor.id(),actor.name()));
summary="已确认接手:"+handoff.to().name()+";"+Objects.toString(note,"本人确认");
}
default -> throw bad("不支持的工作命令");
}
store.save(state); affected.add(key);
var result=new CommandResult(queries.current(state,fact,actor),List.copyOf(affected));
store.history(state,actor,request.command().name(),summary,request.idempotencyKey(),hash,result);
return result;
}
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>();
for(var state:candidates) { var fact=facts.get(state.getWorkItemKey()); if(!completed(fact) && arranged(state,fact)) {
state.setReviewRequired(true); state.setReviewVersion(state.getReviewVersion()+1); state.setReviewReason(reason); store.save(state);
store.history(state,actor,"RELATED_CHANGE",reason,null,null,null); affected.add(state.getWorkItemKey());
} }
return affected;
}
private Responsibility responsibility(JsonNode node,Actor actor) {
fields(node,Set.of("kind","id","name"),Set.of("kind","id","name"));
ResponsibilityKind kind; try { kind=ResponsibilityKind.valueOf(text(node,"kind",true,16)); } catch(IllegalArgumentException e) { throw bad("责任方类型无效"); }
var id=text(node,"id",false,64); var name=text(node,"name",true,200);
if(kind==ResponsibilityKind.USER) { var owner=gateway.owner(id,actor); return new Responsibility(kind,owner.id(),owner.name()); }
if(kind==ResponsibilityKind.SUPPLIER) { if(!actor.has("platform:supplier:list")) throw forbidden("缺少供应商读取权限"); return gateway.supplier(id); }
if(id!=null) throw bad("外部责任方不接受未经核验的内部标识");
return new Responsibility(kind,null,name);
}
private static void requireReturn(AppointmentWorkState state) { if(state.getType()!=Type.EMPTY_RETURN) throw bad("仅还空事项可交接责任"); }
static void fields(JsonNode node,Set<String> allowed,Set<String> required) {
if(node==null || !node.isObject()) throw bad("命令payload须为对象");
var names=new HashSet<String>(); node.fieldNames().forEachRemaining(names::add);
if(!allowed.containsAll(names) || !names.containsAll(required)) throw bad("命令payload包含不支持的字段或缺少必需字段");
}
static String text(JsonNode node,String field,boolean required,int max) {
var value=node.get(field);
if(value==null || value.isNull()) { if(required) throw bad("请填写"+field); return null; }
if(!value.isTextual() || value.textValue().length()>max) throw bad(field+"格式无效");
var result=value.textValue().trim(); if(required && result.isEmpty()) throw bad("请填写"+field); return result.isEmpty()?null:result;
}
private int integer(JsonNode node,String name) { var value=node.get(name); if(value==null || !value.isIntegralNumber() || !value.canConvertToInt() || value.intValue()<0) throw bad(name+"必须为有效版本"); return value.intValue(); }
private LocalDate date(JsonNode value) { if(value==null || value.isNull()) return null; try { if(!value.isTextual()) throw bad("日期格式无效"); return LocalDate.parse(value.textValue()); } catch(java.time.DateTimeException e) { throw bad("日期格式无效"); } }
private Slot slot(JsonNode node) {
if(node==null || node.isNull()) return null;
fields(node,Set.of("date","startTime","endDate","endTime","timezone"),Set.of("date","startTime","endDate","endTime","timezone"));
try { return new Slot(date(node.get("date")),time(node.get("startTime")),date(node.get("endDate")),time(node.get("endTime")),text(node,"timezone",true,64)); }
catch(java.time.DateTimeException e) { throw bad("目标日期或时段格式无效"); }
}
private LocalTime time(JsonNode value) { if(value.isNull()) return null; if(!value.isTextual() || !value.textValue().matches("\\d{2}:\\d{2}")) throw bad("时段须为HH:mm"); return LocalTime.parse(value.textValue()); }
static String canonical(JsonNode node) {
if(node.isObject()) { var parts=new TreeMap<String,String>(); node.fields().forEachRemaining(e->parts.put(e.getKey(),canonical(e.getValue()))); return parts.toString(); }
if(node.isArray()) { var parts=new ArrayList<String>(); node.forEach(v->parts.add(canonical(v))); return parts.toString(); }
return node.toString();
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Audit baseline for semantic impacts. Written only by original business transactions, never by reads. */
@Component
public class AppointmentWorkCommitmentService {
public record Commitment(Slot target,Slot confirmed,String assignment,String cargoToken,boolean cancelled) { }
private final AppointmentWorkMapper mapper;
private final ObjectMapper json;
public AppointmentWorkCommitmentService(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
@Transactional(propagation=Propagation.MANDATORY)
public void record(String key,Commitment current) {
var row=mapper.lockCommitment(key);
try {
if(row==null) mapper.insertCommitment(key,json.writeValueAsString(current));
else {
var previous=json.readValue(row.getStateJson(),Commitment.class);
mapper.updateCommitment(key,json.writeValueAsString(current),row.getImpactVersion()+(impacts(previous,current)?1:0));
}
} catch(com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException("无法保存原业务承诺影响依据",error); }
}
public String impactToken(String key) {
var row=mapper.commitment(key);
return key+":"+(row==null?0:row.getImpactVersion());
}
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());
if(old.target()!=null && !Objects.equals(old.target(),now.target()) && !(firstSameDayConfirmation && now.target()==null)) return true;
if(old.confirmed()!=null && !Objects.equals(old.confirmed(),now.confirmed())) return true;
if(old.confirmed()==null && now.confirmed()!=null && old.target()!=null && !old.target().date().equals(now.confirmed().date())) return true;
if(old.assignment()!=null && !Objects.equals(old.assignment(),now.assignment())) return true;
return old.cargoToken()!=null && !Objects.equals(old.cargoToken(),now.cargoToken());
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.Set;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-work")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public class AppointmentWorkController {
private final AppointmentWorkQueryService queries;
private final AppointmentWorkCommandService commands;
public AppointmentWorkController(AppointmentWorkQueryService queries,AppointmentWorkCommandService commands) { this.queries=queries; this.commands=commands; }
@GetMapping("/calendar")
public ApiResponse<CalendarResult> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam LocalDate from,@RequestParam LocalDate to,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set<Type> types) {
return ApiResponse.success(queries.calendar(from,to,flow,types,actor(user)));
}
@GetMapping("/summary")
public ApiResponse<Summary> summary(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(queries.summary(actor(user))); }
@GetMapping("/items")
public ApiResponse<PageResult<WorkItem>> items(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue="ALL") View view,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size,
@RequestParam(required=false) LocalDate date,@RequestParam(required=false) Flow flow,@RequestParam(required=false) Set<Type> types,
@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/{workItemKey}")
public ApiResponse<WorkDetail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey) {
return ApiResponse.success(queries.detail(workItemKey,actor(user)));
}
@PostMapping("/items/{workItemKey}/commands")
public ApiResponse<CommandResult> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String workItemKey,@Valid @RequestBody CommandRequest request) {
return ApiResponse.success(commands.execute(workItemKey,request,actor(user)));
}
static Actor actor(AuthenticatedUser user) {
if(user==null || user.userId()==null) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.UNAUTHORIZED);
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()) || !user.permissions().contains(LIST)) throw forbidden("仅平台有权访问工作事项");
return new Actor(user.userId().toString(),user.displayName()==null?user.username():user.displayName(),user.permissions());
}
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** Direct transport uses terminal carrier arrangements and real destination unloading, never warehouse stock. */
@Component
public class AppointmentWorkDirectTransportProvider implements BusinessProvider {
private final AppointmentWorkFirstlegClient firstleg;
private final String token;
public AppointmentWorkDirectTransportProvider(AppointmentWorkFirstlegClient firstleg,@Value("${baiyue.security.internal-service-token:}") String token) { this.firstleg=firstleg; this.token=token; }
public boolean supports(Type type,Flow flow) { return type==Type.TRANSPORT && flow==Flow.DIRECT; }
public List<Fact> read(List<FactRequest> requests) {
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var input=new ArrayList<FactRequest>();
for(var r:requests) { input.add(request(r,Type.PICKUP,"/carrier")); input.add(request(r,Type.DELIVERY,"/delivery")); }
var facts=new HashMap<String,Fact>();
for(int start=0;start<input.size();start+=200) {
var batch=input.subList(start,Math.min(start+200,input.size())); var response=firstleg.facts(token,batch);
if(response==null || !response.successful()) throw unavailable("直送承运及卸货事实不可用");
AppointmentWorkFactAdapter.append(facts,batch,response.data());
}
return requests.stream().map(r->{
var carrier=facts.get(r.workItemKey()+"/carrier"); var delivery=facts.get(r.workItemKey()+"/delivery");
var original=carrier.executionStatus()==ExecutionStatus.COMPLETED?delivery.action():carrier.action();
var action=original==null?null:new Action(original.code(),original.label(),r.workItemKey(),original.target());
var read=new LinkedHashSet<>(carrier.readPermissions());read.addAll(delivery.readPermissions());
var missing=new LinkedHashSet<>(carrier.missingFields());missing.addAll(delivery.missingFields());
var refs=new LinkedHashSet<>(carrier.references());refs.addAll(delivery.references());
boolean done=delivery.executionStatus()==ExecutionStatus.COMPLETED;
return new Fact(r.workItemKey(),digest(carrier.fingerprint()+":"+delivery.fingerprint()),carrier.impactToken(),carrier.planExists(),carrier.planStatus(),
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());
}).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()); }
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.appointmentwork;
import jakarta.validation.constraints.*;
import java.util.List;
/** Real dispatch allocation boundary, implemented by the original appointment domain. */
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) { }
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);
void removeItem(long dispatchId,long itemId);
void requireTripBinding(String dispatchNo,long tripId);
}
@@ -0,0 +1,112 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
private final AppointmentWorkFirstlegClient firstleg;
private final List<BusinessProvider> providers;
private final ObjectProvider<OwnerDirectory> directory;
private final ObjectProvider<RequirementProvider> requirements;
private final ObjectProvider<SupplierDirectory> suppliers;
private final String token;
public AppointmentWorkFactAdapter(AppointmentWorkFirstlegClient firstleg, ObjectProvider<BusinessProvider> providers,
ObjectProvider<OwnerDirectory> directory, ObjectProvider<RequirementProvider> requirements, ObjectProvider<SupplierDirectory> suppliers,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.firstleg=firstleg; this.providers=providers.orderedStream().toList(); this.directory=directory; this.requirements=requirements; this.suppliers=suppliers; this.token=token;
}
public SourcePage discover(String after, int size) {
requireToken();
try { var response=firstleg.discover(token,after,size);
if(response==null || !response.successful() || response.data()==null) throw unavailable("已开船来源读取失败");
var page=response.data();
if(page.scopeVersion()==null || page.scopeVersion().isBlank() || page.containers()==null || page.containers().size()>size || !page.complete() && (page.nextCursor()==null || page.containers().isEmpty() || Objects.equals(after,page.nextCursor())))
throw unavailable("已开船来源分页不完整");
return page;
} catch(RuntimeException error) { throw unavailable("已开船来源不可用,不能计算完整工作范围",error); }
}
public ContainerSource container(String id) {
requireToken();
try { var response=firstleg.container(token,id);
if(response==null || !response.successful() || response.data()==null || !id.equals(response.data().containerBookingId())) throw unavailable("柜次事实缺失");
return response.data();
} catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); }
}
public Map<String,Fact> facts(List<AppointmentWorkState> states) {
var result=new LinkedHashMap<String,Fact>();
var remote=new ArrayList<FactRequest>();
var groups=new LinkedHashMap<BusinessProvider,List<FactRequest>>();
for(var state:states) {
if(state.getType()==Type.ROUTE) continue;
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),state.getContainerBookingId(),state.getRequirementKey(),state.getBatchKey(),state.getTripKey(),state.getBatchKeys(),state.getContainers(),state.getSources());
if(state.getType()==Type.PICKUP || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && state.getFlow()==Flow.DIRECT) remote.add(request);
else {
var matches=providers.stream().filter(p->p.supports(state.getType(),state.getFlow())).toList();
if(matches.size()!=1) throw unavailable("原业务事实适配尚未就绪:"+state.getType());
groups.computeIfAbsent(matches.get(0),ignored->new ArrayList<>()).add(request);
}
}
for(int start=0; start<remote.size(); start+=200) {
var requests=remote.subList(start,Math.min(start+200,remote.size())); requireToken();
try { var response=firstleg.facts(token,requests);
if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败");
append(result,requests,response.data());
} catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); }
}
groups.forEach((provider,requests)->{
for(int start=0; start<requests.size(); start+=200) {
var batch=requests.subList(start,Math.min(start+200,requests.size()));
try { append(result,batch,provider.read(batch)); }
catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); }
}
});
return result;
}
public List<Requirement> requirements(ContainerSource source,Flow effectiveFlow) {
var provider=requirements.getIfAvailable();
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
var result=provider.requirements(source,effectiveFlow);
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
return List.copyOf(result);
}
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());
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("完成事实缺少实际时间");
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());
if(fact.timezone()!=null) zone(fact.timezone());
if(fact.actual()!=null && fact.actual().timezone()!=null) zone(fact.actual().timezone());
if(fact.action()!=null && (fact.action().target()==null || fact.action().target() instanceof WorkTarget || fact.actionPermissions().isEmpty())) throw unavailable("原业务动作缺少独立权限或越过工作命令边界");
if(target.putIfAbsent(fact.workItemKey(),fact)!=null) throw unavailable("工作事实重复");
}
}
public Owner owner(String id,Actor actor) {
if(id==null || id.isBlank()) throw bad("请选择负责人");
var resolver=directory.getIfAvailable();
if(resolver==null) throw unavailable("负责人目录尚未接入,不能确认其他接手人");
var owner=resolver.activePlatformOwner(id);
if(owner==null || !id.equals(owner.id())) throw bad("接手人不是有效平台操作人员");
return owner;
}
public Responsibility supplier(String id) {
if(id==null || id.isBlank()) throw bad("请选择供应商责任方");
var provider=suppliers.getIfAvailable();
if(provider==null) throw unavailable("供应商权威目录适配尚未接入");
var result=provider.activeSupplier(id);
if(result==null || result.kind()!=ResponsibilityKind.SUPPLIER || !id.equals(result.id()) || result.name()==null || result.name().isBlank()) throw bad("供应商责任方无效");
return result;
}
private void requireToken() { if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置"); }
}
@@ -0,0 +1,43 @@
package com.baiyue.lastleg.appointmentwork;
import java.time.Instant;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Authority ports. A missing entity must be explicitly represented; missing responses are failures. */
public interface AppointmentWorkFactGateway {
record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind,
String originName, String destinationName, String timezone, List<BusinessReference> references,
List<String> replacesRequirementKeys, List<SourceReference> sources) { }
record ContainerSource(String containerBookingId, String containerNo, Instant departedAt, java.time.LocalDateTime departedLocalAt, java.time.LocalDateTime sailedEvidenceLocalAt,
String sourceVersion, Flow establishedFlow, String timezone, List<Requirement> requirements) { }
record SourcePage(List<ContainerSource> containers, String nextCursor, boolean complete, String scopeVersion) { }
record FactRequest(String workItemKey, Type type, Flow flow, String containerBookingId,
String requirementKey, String batchKey, String tripKey, List<String> batchKeys, List<ContainerReference> containers,
List<SourceReference> sources) { }
/** impactToken changes only for real changes to an existing commitment, never first arrangement/assignment. */
record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, PlanStatus planStatus,
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; }
}
SourcePage discover(String after, int size);
ContainerSource container(String containerBookingId);
/** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */
List<Requirement> requirements(ContainerSource source,Flow effectiveFlow);
Map<String, Fact> facts(List<AppointmentWorkState> states);
Owner owner(String id, Actor actor);
Responsibility supplier(String id);
/** Implemented in original Lastleg business domains; they may depend on this port, never vice versa. */
interface BusinessProvider {
boolean supports(Type type, Flow flow);
List<Fact> read(List<FactRequest> requests);
}
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
interface OwnerDirectory { Owner activePlatformOwner(String id); }
interface SupplierDirectory { Responsibility activeSupplier(String id); }
interface RequirementProvider { List<Requirement> requirements(ContainerSource source,Flow effectiveFlow); }
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
/** Provider contract pending original-domain integration; HTTP errors are never treated as empty facts. */
@FeignClient(name="firstleg-service", contextId="appointmentWorkFirstlegClient",
url="${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}")
public interface AppointmentWorkFirstlegClient {
@GetMapping("/internal/firstleg/appointment-work/containers")
ApiResponse<SourcePage> discover(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam(value="after", required=false) String after, @RequestParam("size") int size);
@GetMapping("/internal/firstleg/appointment-work/containers/{id}")
ApiResponse<ContainerSource> container(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("id") String id);
@PostMapping("/internal/firstleg/appointment-work/facts")
ApiResponse<List<Fact>> facts(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody List<FactRequest> requests);
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Internal orchestration only, not exposed through the public gateway. */
@RestController
@RequestMapping("/internal/lastleg/appointment-work")
public class AppointmentWorkInternalController {
private final InternalServiceTokenGuard guard;
private final AppointmentWorkSyncService sync;
private final AppointmentWorkRouteService routes;
public AppointmentWorkInternalController(InternalServiceTokenGuard guard,AppointmentWorkSyncService sync,AppointmentWorkRouteService routes) { this.guard=guard; this.sync=sync; this.routes=routes; }
@GetMapping("/containers/{id}/route")
public ApiResponse<AppointmentWorkRouteService.Route> route(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@PathVariable String id) {
guard.verify(token); return ApiResponse.success(routes.route(id));
}
@PostMapping("/synchronize")
public ApiResponse<SyncResult> synchronize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody SyncRequest request) {
guard.verify(token); return ApiResponse.success(sync.synchronize(request));
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.appointmentwork;
import lombok.Data;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentWorkMapper {
@Data class Row {
private String workItemKey;
private String containerBookingId;
private int version;
private boolean retired;
private String stateJson;
}
@Data class Guard {
private int id;
private String scanCursor;
private boolean initialized;
private String scopeVersion;
private String scanScopeVersion;
private LocalDateTime completedAt;
private long requirementVersion;
private long synchronizedRequirementVersion;
private long scanRequirementVersion;
}
@Data class History {
private String id;
private String workItemKey;
private String operatorId;
private String operatorName;
private String action;
private String summary;
private String commandKey;
private String requestHash;
private String responseJson;
private LocalDateTime occurredAt;
}
@Data class CommitmentRow {
private String entityKey;
private String stateJson;
private long impactVersion;
}
@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);
@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}")
int updateCommitment(@Param("key") String key,@Param("json") String json,@Param("version") long version);
@Select("SELECT * FROM ll_appointment_work_guard WHERE id=1 FOR UPDATE") Guard lockGuard();
@Select("SELECT * FROM ll_appointment_work_guard WHERE id=1") Guard guard();
@Update("UPDATE ll_appointment_work_guard SET scan_cursor=#{scanCursor},initialized=#{initialized},completed_at=#{completedAt},scope_version=#{scopeVersion},scan_scope_version=#{scanScopeVersion},synchronized_requirement_version=#{synchronizedRequirementVersion},scan_requirement_version=#{scanRequirementVersion} WHERE id=1") int saveGuard(Guard row);
@Update("UPDATE ll_appointment_work_guard SET requirement_version=requirement_version+1 WHERE id=1") int invalidateRequirements();
@Select("SELECT * FROM ll_appointment_work WHERE work_item_key=#{key}") Row get(String key);
@Select("SELECT * FROM ll_appointment_work ORDER BY work_item_key LIMIT #{limit}") List<Row> all(int limit);
@Select("SELECT * FROM ll_appointment_work WHERE container_booking_id=#{id} ORDER BY work_item_key") List<Row> container(String id);
@Insert("INSERT INTO ll_appointment_work(work_item_key,container_booking_id,version,retired,state_json) VALUES(#{workItemKey},#{containerBookingId},#{version},#{retired},#{stateJson})") int insert(Row row);
@Update("UPDATE ll_appointment_work SET version=#{version},retired=#{retired},state_json=#{stateJson} WHERE work_item_key=#{workItemKey} AND version=#{version}-1") int update(Row row);
@Select("SELECT * FROM ll_appointment_work_history WHERE work_item_key=#{key} AND command_key=#{commandKey}")
History command(@Param("key") String key,@Param("commandKey") String commandKey);
@Insert("""
INSERT INTO ll_appointment_work_history(id,work_item_key,operator_id,operator_name,action,summary,command_key,request_hash,response_json,occurred_at)
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);
}
@@ -0,0 +1,75 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.*;
import java.time.*;
import java.util.*;
/** Public v1 contracts. No editable cargo quantities or execution commands live here. */
public final class AppointmentWorkModels {
private AppointmentWorkModels() { }
public enum Type { ROUTE, PICKUP, WAREHOUSE_RECEIPT, DELIVERY, TRANSPORT, EMPTY_RETURN }
public enum Flow { TRANSFER, DIRECT, UNKNOWN }
public enum PlanStatus { UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED }
public enum ExecutionStatus { PENDING, IN_PROGRESS, COMPLETED }
public enum DateBasis { ACTUAL, CONFIRMED, TARGET, NONE }
public enum View { ALL, FOLLOW_TODAY, OVERDUE, UNDATED, REVIEW }
public enum TargetKind { WORK_COMMAND, CARGO_VERIFICATION, FBA_APPOINTMENT, FIRSTLEG_CONTAINER, FLEET_TRIP, WAREHOUSE_RECEIPT, READ_ONLY }
public enum Command { CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN }
public enum Bucket { PENDING, CONFIRMED, NO_BOOKING_REQUIRED, REVIEW, COMPLETED }
public enum ActionCode { VIEW_DETAIL, SAVE_PLAN, REQUEST_BOOKING, RECORD_CONFIRMATION, CHANGE_BOOKING, CANCEL_BOOKING, VERIFY_CARGO, EDIT_BATCH, ARRANGE_TRANSPORT, RECORD_EXECUTION, CONFIRM_ROUTE, SAVE_FOLLOWUP, RESOLVE_REVIEW, SAVE_REQUIREMENT, HANDOFF_RETURN, ACCEPT_RETURN, VIEW_HISTORY }
public record Slot(LocalDate date, @com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime startTime, LocalDate endDate,
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using=com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class) LocalTime endTime, String timezone) { }
public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { }
public record Owner(String id, String name) { }
public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { }
public record SourceReference(String customerId, String sourceKey, String destinationKey) { }
@JsonTypeInfo(use=JsonTypeInfo.Id.NAME, property="kind")
@JsonSubTypes({@JsonSubTypes.Type(value=WorkTarget.class,name="WORK_COMMAND"), @JsonSubTypes.Type(value=CargoTarget.class,name="CARGO_VERIFICATION"),
@JsonSubTypes.Type(value=AppointmentTarget.class,name="FBA_APPOINTMENT"), @JsonSubTypes.Type(value=ContainerTarget.class,name="FIRSTLEG_CONTAINER"),
@JsonSubTypes.Type(value=TripTarget.class,name="FLEET_TRIP"), @JsonSubTypes.Type(value=ReceiptTarget.class,name="WAREHOUSE_RECEIPT"), @JsonSubTypes.Type(value=ReadTarget.class,name="READ_ONLY")})
public sealed interface ActionTarget permits WorkTarget,CargoTarget,AppointmentTarget,ContainerTarget,TripTarget,ReceiptTarget,ReadTarget { }
public record WorkTarget(Command command) implements ActionTarget { }
public record CargoTarget(SourceReference source) implements ActionTarget { }
public record AppointmentTarget(String appointmentId,String batchKey,List<SourceReference> sources) implements ActionTarget { }
public record ContainerTarget(String containerBookingId) implements ActionTarget { }
public record TripTarget(String tripId,List<String> batchKeys) implements ActionTarget { }
public record ReceiptTarget(String inboundPlanId,String inboundPlanNo,String customerId,String containerBookingId) implements ActionTarget { }
public record ReadTarget(List<BusinessReference> references) implements ActionTarget { }
public record Action(ActionCode code, String label, String targetWorkItemKey, ActionTarget target) { }
public record WorkItem(String workItemKey, int version, Type type, Flow flow, String title,
String businessNo, List<ContainerReference> containers, String requirementKey,
String batchKey, String batchNo, String originName, String destinationName,
PlanStatus planStatus, ExecutionStatus executionStatus, boolean reviewRequired,
Slot target, Slot confirmed, Actual actual, LocalDate calendarDate, DateBasis dateBasis,
String timezone, Owner owner, LocalDate followupDate, List<String> overdueReasons,
List<Action> allowedActions, Action nextAction, List<String> blockReasons, List<String> missingFields) { }
public record BusinessReference(String kind, String key, String businessNo, String label) { }
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 Review(int version,List<String> reasons) { }
public record RequirementDetails(boolean bookingRequired,String receivingRequirements,String reason) { }
public enum ResponsibilityKind { USER, SUPPLIER, EXTERNAL }
public record Responsibility(ResponsibilityKind kind,String id,String name) { }
public enum HandoffStatus { PENDING_ACCEPTANCE, ACCEPTED }
public record ReturnHandoff(int version,Responsibility from,Responsibility to,HandoffStatus status,String reason) { }
public record Summary(long all, long followToday, long overdue, long undated, long review) { }
public record BucketCounts(long pending, long confirmed, long noBookingRequired, long review, long completed) { }
public record DayAggregate(LocalDate date, long total, Map<Type, Long> typeCounts, BucketCounts counts) { }
public record CalendarResult(LocalDate from, LocalDate to, long total, BucketCounts counts, List<DayAggregate> days) { }
public record CommandRequest(@NotNull Command command, @Min(0) int expectedVersion,
@NotBlank @Size(max=64) String idempotencyKey, @NotNull JsonNode payload) { }
public record CommandResult(WorkItem item, List<String> affectedWorkItemKeys) { }
public record SyncRequest(@Size(max=128) String afterContainerBookingId, @Min(1) @Max(100) int size) { }
public record SyncResult(int sources, int created, String nextCursor, boolean complete) { }
public record Actor(String id, String name, Set<String> permissions) {
public boolean has(String permission) { return permissions.contains(permission); }
}
public static final String LIST = "platform:appointment:list";
public static final String FOLLOWUP = "platform:appointment:followup";
public static final String MANAGE = "platform:appointment:manage-work";
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="iam-service",contextId="appointmentWorkOwnerClient",url="${baiyue.clients.iam.url:}")
public interface AppointmentWorkOwnerClient {
@GetMapping("/internal/iam/platform-owners/{authUserId}")
ApiResponse<AppointmentWorkModels.Owner> active(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("authUserId") String id);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkOwnerDirectory implements AppointmentWorkFactGateway.OwnerDirectory {
private final AppointmentWorkOwnerClient client;
private final String token;
public AppointmentWorkOwnerDirectory(AppointmentWorkOwnerClient client,@Value("${baiyue.security.internal-service-token:}") String token) { this.client=client; this.token=token; }
public AppointmentWorkModels.Owner activePlatformOwner(String id) {
if(token==null || token.isBlank()) throw unavailable("内部服务身份未配置");
var response=client.active(token,id);
if(response==null || !response.successful() || response.data()==null) throw unavailable("平台人员权威目录读取失败");
var owner=response.data();
if(!id.equals(owner.id()) || owner.name()==null || owner.name().isBlank()) throw unavailable("平台人员目录返回身份或姓名不完整");
return owner;
}
}
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
public final class AppointmentWorkPolicy {
/** Explicit business fallback for reminder dates only; never used to fabricate actual/appointment time. */
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 key(String containerId,Type type,String requirementKey) {
return "AW-"+type.name()+"-"+digest(containerId+"\n"+Objects.toString(requirementKey,"")).substring(0,32);
}
public static String digest(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch(Exception e) { throw new IllegalStateException(e); }
}
public static void validateSlot(Slot slot) {
if(slot==null) return;
if(slot.date()==null || slot.timezone()==null || slot.timezone().isBlank()) throw bad("目标日期与地点时区必填");
validateSourceSlot(slot);
var zone=zone(slot.timezone());
for(int index=0; index<2; index++) { var time=index==0?slot.startTime():slot.endTime(); if(time==null) continue;
if(time.getSecond()!=0 || time.getNano()!=0) throw bad("时间精度须为分钟");
if(zone.getRules().getValidOffsets(LocalDateTime.of(index==0?slot.date():slot.endDate(),time)).size()!=1) throw bad("当地时段存在夏令时跳变或歧义,请重新选择");
}
}
/** Original local plans remain visible even when their source did not record a timezone. */
public static void validateSourceSlot(Slot slot) {
if(slot==null) return;
if(slot.date()==null) throw unavailable("原计划缺少当地日期");
if(slot.timezone()!=null) zone(slot.timezone());
if((slot.endDate()==null)!=(slot.endTime()==null) || slot.startTime()==null && slot.endTime()!=null) throw bad("结束日期与时间须同时填写,并先明确开始时间");
if(slot.endTime()!=null && !LocalDateTime.of(slot.endDate(),slot.endTime()).isAfter(LocalDateTime.of(slot.date(),slot.startTime()))) throw bad("完整结束时间必须晚于开始时间,不自动推断次日");
}
public static void validateActual(Actual actual) {
if(actual.occurredAt()==null && actual.localOccurredAt()==null) throw unavailable("实际事实缺少发生时间");
if(actual.timezone()!=null) {
var zone=zone(actual.timezone());
if(actual.occurredAt()!=null && actual.localOccurredAt()!=null && !actual.occurredAt().atZone(zone).toLocalDateTime().equals(actual.localOccurredAt()))
throw unavailable("实际发生时刻与当地时间不一致");
}
}
public static ZoneId zone(String value) {
if(value==null || !ZoneId.getAvailableZoneIds().contains(value)) throw bad("需要有效IANA地点时区");
return ZoneId.of(value);
}
public static boolean pendingHandoff(AppointmentWorkState state) {
return state.getReturnHandoff()!=null && state.getReturnHandoff().status()==HandoffStatus.PENDING_ACCEPTANCE;
}
public static boolean canRead(AppointmentWorkState state,Fact fact,Actor actor) {
return actor.has(LIST) && (state.getType()==Type.ROUTE ? actor.has(ORDER_READ) : fact!=null && fact.readPermissions().stream().allMatch(actor::has));
}
public static boolean arranged(AppointmentWorkState state,Fact fact) {
return state.getTarget()!=null || !state.isBookingRequired() || fact!=null && fact.arranged();
}
public static boolean changed(AppointmentWorkState state,Fact fact) {
return fact!=null && state.getObservedFingerprint()!=null && !Objects.equals(state.getObservedFingerprint(),fact.fingerprint());
}
public static boolean completed(Fact fact) { return fact!=null && fact.executionStatus()==ExecutionStatus.COMPLETED; }
public static WorkItem project(AppointmentWorkState state,Fact fact,Actor actor,Clock clock) {
boolean route=state.getType()==Type.ROUTE;
if(!route && fact==null) throw unavailable("缺少事项权威事实");
var target=fact!=null && fact.planExists()?fact.target():state.getTarget();
var confirmed=fact==null?null:fact.confirmed();
var actual=fact==null?null:fact.actual();
var execution=route?state.isRetired()?ExecutionStatus.COMPLETED:ExecutionStatus.PENDING:fact.executionStatus();
var plan=!state.isBookingRequired()?PlanStatus.NOT_REQUIRED:fact!=null && fact.planExists()?fact.planStatus():target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED;
var basis=actual!=null?DateBasis.ACTUAL:confirmed!=null?DateBasis.CONFIRMED:target!=null?DateBasis.TARGET:DateBasis.NONE;
String timezone=actual!=null?actual.timezone():confirmed!=null?confirmed.timezone():target!=null?target.timezone():fact!=null && fact.timezone()!=null?fact.timezone():state.getTimezone();
LocalDate date=null;
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>();
if(fact!=null) { blocks.addAll(fact.blockReasons()); missing.addAll(fact.missingFields()); }
if(actual!=null) {
validateActual(actual);
if(timezone!=null && actual.occurredAt()!=null) date=actual.occurredAt().atZone(zone(timezone)).toLocalDate();
else if(actual.localOccurredAt()!=null) date=actual.localOccurredAt().toLocalDate();
if(timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE");
}
else if(confirmed!=null) { validateSourceSlot(confirmed); date=confirmed.date(); }
else if(target!=null) { validateSourceSlot(target); date=target.date(); }
if((confirmed!=null || target!=null) && timezone==null && !missing.contains("SOURCE_TIMEZONE")) missing.add("SOURCE_TIMEZONE");
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("负责人待分配");
var overdue=new ArrayList<String>();
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
if(state.getFollowupDate()!=null && state.getFollowupDate().isBefore(today)) overdue.add("跟进日期已过");
if(date!=null && date.isBefore(today)) overdue.add("作业日期已过,尚未完成");
}
if(pendingHandoff(state) && execution!=ExecutionStatus.COMPLETED) blocks.add("还空接手尚未确认,继续跟进");
if(changed(state,fact)) blocks.add("原业务事实已变化,须同步影响后再复核");
var actions=new ArrayList<Action>();
if(execution!=ExecutionStatus.COMPLETED && !state.isRetired()) {
if(actor.has(FOLLOWUP)) actions.add(work(state,Command.SAVE_FOLLOWUP,"记录回复与跟进"));
if(route && actor.has(MANAGE)) actions.add(work(state,Command.CONFIRM_ROUTE,"确认路线"));
if(state.isReviewRequired() && actor.has(FOLLOWUP) && !changed(state,fact)) actions.add(work(state,Command.RESOLVE_REVIEW,"复核安排"));
if(actor.has(MANAGE) && requirementOwned(state,fact)) actions.add(work(state,Command.SAVE_REQUIREMENT,"登记作业要求"));
if(state.getType()==Type.EMPTY_RETURN && actor.has(MANAGE)) {
actions.add(work(state,Command.HANDOFF_RETURN,"转交还空责任"));
if(pendingHandoff(state) && (state.getReturnHandoff().to().kind()!=ResponsibilityKind.USER || actor.id().equals(state.getReturnHandoff().to().id()))) actions.add(work(state,Command.ACCEPT_RETURN,"确认接手反馈"));
}
if(fact!=null && fact.action()!=null) {
boolean executionBlocked=fact.action().code()==ActionCode.RECORD_EXECUTION && (!fact.blockReasons().isEmpty() || state.getType()!=Type.EMPTY_RETURN && (state.isReviewRequired() || changed(state,fact)));
if(fact.actionPermissions().stream().allMatch(actor::has) && !executionBlocked) actions.add(fact.action());
else if(!fact.actionPermissions().stream().allMatch(actor::has)) blocks.add("缺少原业务操作权限,可查看安排并联系有权限人员处理");
}
}
var read=new Action(execution==ExecutionStatus.COMPLETED?ActionCode.VIEW_HISTORY:ActionCode.VIEW_DETAIL,"查看详情",state.getWorkItemKey(),new ReadTarget(state.getReferences()));
actions.add(read);
Action next=actions.stream().filter(a->a.code()==ActionCode.ACCEPT_RETURN).findFirst().orElse(null);
if(next==null && pendingHandoff(state)) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read);
if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.CONFIRM_ROUTE || a.code()==ActionCode.RESOLVE_REVIEW).findFirst().orElse(null);
if(next==null && fact!=null && fact.action()!=null && actions.contains(fact.action())) next=fact.action();
if(next==null) next=actions.stream().filter(a->a.code()==ActionCode.SAVE_REQUIREMENT || a.code()==ActionCode.SAVE_FOLLOWUP).findFirst().orElse(read);
var names=Map.of(Type.ROUTE,"确认路线",Type.PICKUP,"提柜",Type.WAREHOUSE_RECEIPT,"接仓卸柜",Type.DELIVERY,"送仓",Type.TRANSPORT,"用车",Type.EMPTY_RETURN,"还空柜");
var businessNo=fact==null?null:fact.businessNo();
var label=businessNo!=null?businessNo:state.getBatchNo()!=null?state.getBatchNo():state.getContainerNo()!=null?state.getContainerNo():"—";
return new WorkItem(state.getWorkItemKey(),state.getVersion(),state.getType(),state.getFlow(),label+" · "+names.get(state.getType()),businessNo,
state.getContainers().isEmpty()?List.of(new ContainerReference(state.getContainerBookingId(),null,state.getContainerNo())):List.copyOf(state.getContainers()),state.getRequirementKey(),state.getBatchKey(),state.getBatchNo(),
state.getOriginName(),state.getDestinationName(),plan,execution,state.isReviewRequired() && execution!=ExecutionStatus.COMPLETED,
target,confirmed,actual,date,basis,timezone,state.getOwner(),state.getFollowupDate(),List.copyOf(overdue),List.copyOf(actions),next,List.copyOf(blocks),List.copyOf(missing));
}
public static boolean requirementOwned(AppointmentWorkState state,Fact fact) {
return (fact==null || !fact.planExists()) && (state.getType()==Type.WAREHOUSE_RECEIPT || state.getType()==Type.EMPTY_RETURN || state.getType()==Type.DELIVERY && "CUSTOMER".equals(state.getDestinationKind()));
}
public static Action work(AppointmentWorkState state,Command command,String label) {
return new Action(ActionCode.valueOf(command.name()),label,state.getWorkItemKey(),new WorkTarget(command));
}
public static boolean followToday(WorkItem item,Clock clock) {
var today=LocalDate.now(clock.withZone(item.timezone()==null?REMINDER_ZONE:zone(item.timezone())));
return item.followupDate()!=null && !item.followupDate().isAfter(today) || item.blockReasons().contains("还空接手尚未确认,继续跟进");
}
public static Bucket bucket(WorkItem item) {
return item.executionStatus()==ExecutionStatus.COMPLETED?Bucket.COMPLETED:item.reviewRequired()?Bucket.REVIEW:
item.planStatus()==PlanStatus.CONFIRMED?Bucket.CONFIRMED:item.planStatus()==PlanStatus.NOT_REQUIRED?Bucket.NO_BOOKING_REQUIRED:Bucket.PENDING;
}
public static BucketCounts counts(List<WorkItem> items) {
var counts=new EnumMap<Bucket,Long>(Bucket.class); items.forEach(i->counts.merge(bucket(i),1L,Long::sum));
return new BucketCounts(counts.getOrDefault(Bucket.PENDING,0L),counts.getOrDefault(Bucket.CONFIRMED,0L),counts.getOrDefault(Bucket.NO_BOOKING_REQUIRED,0L),counts.getOrDefault(Bucket.REVIEW,0L),counts.getOrDefault(Bucket.COMPLETED,0L));
}
public static List<String> effectiveKeys(Collection<String> keys,Map<String,AppointmentWorkState> states) {
var pending=new ArrayDeque<>(keys); var seen=new HashSet<String>(); var effective=new LinkedHashSet<String>();
while(!pending.isEmpty()) {
var key=pending.remove(); if(!seen.add(key)) continue;
var state=states.get(key);
if(state!=null && state.isRetired() && !state.getReplacementKeys().isEmpty()) pending.addAll(state.getReplacementKeys());
else effective.add(key);
}
return List.copyOf(effective);
}
public static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,text); }
public static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT,text); }
public static ResponseStatusException missing(String text) { return new ResponseStatusException(HttpStatus.NOT_FOUND,text); }
public static ResponseStatusException forbidden(String text) { return new ResponseStatusException(HttpStatus.FORBIDDEN,text); }
public static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text); }
public static ResponseStatusException unavailable(String text,Throwable cause) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,text,cause); }
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.PageResult;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
@Transactional(readOnly=true)
public class AppointmentWorkQueryService {
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway facts;
private final Clock clock;
@org.springframework.beans.factory.annotation.Autowired
public AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts) { this(store,facts,Clock.systemUTC()); }
AppointmentWorkQueryService(AppointmentWorkStore store,AppointmentWorkFactGateway facts,Clock clock) { this.store=store; this.facts=facts; this.clock=clock; }
record Snapshot(List<AppointmentWorkState> states,Map<String,Fact> facts,List<WorkItem> items) { }
Snapshot snapshot(Actor actor) {
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 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());
}
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();
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)) {
var action=next(prior,items,states,visited);
if(action!=null) return new Action(action.code(),"先"+action.label().replaceFirst("^先",""),action.targetWorkItemKey(),action.target());
}
}
return item.nextAction();
}
public Summary summary(Actor actor) {
var items=snapshot(actor).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<Type> 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))
.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();
return new CalendarResult(from,to,items.size(),counts(items),days);
}
public PageResult<WorkItem> items(View view,int page,int size,LocalDate date,Flow flow,Set<Type> 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()
.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()))
.filter(i->containerId==null || i.containers().stream().anyMatch(c->containerId.equals(c.containerBookingId())))
.filter(i->bucket==null || bucket==bucket(i))
.filter(i->word.isEmpty() || searchable(i).contains(word))
.filter(i->switch(view) { case ALL->true; case FOLLOW_TODAY->i.executionStatus()!=ExecutionStatus.COMPLETED && followToday(i,clock); case OVERDUE->!i.overdueReasons().isEmpty(); case UNDATED->i.calendarDate()==null; case REVIEW->i.reviewRequired(); })
.sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList();
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 WorkDetail detail(String key,Actor actor) {
var snapshot=snapshot(actor); var state=store.get(key);
var fact=snapshot.facts().get(key);
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key);
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源");
var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在"));
var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys());
var statesByKey=snapshot.states().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey));
snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add);
var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references());
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key),
state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null,
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 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);
}
private WorkItem withNext(WorkItem i,Action next) {
return new WorkItem(i.workItemKey(),i.version(),i.type(),i.flow(),i.title(),i.businessNo(),i.containers(),i.requirementKey(),i.batchKey(),i.batchNo(),i.originName(),i.destinationName(),i.planStatus(),i.executionStatus(),i.reviewRequired(),i.target(),i.confirmed(),i.actual(),i.calendarDate(),i.dateBasis(),i.timezone(),i.owner(),i.followupDate(),i.overdueReasons(),i.allowedActions(),next,i.blockReasons(),i.missingFields());
}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
@Service
public class AppointmentWorkRouteService {
public record Route(Flow flow) { }
private final AppointmentWorkStore store;
public AppointmentWorkRouteService(AppointmentWorkStore store) { this.store=store; }
/** Local committed state only. No guard lock and no upstream HTTP calls. */
@Transactional(readOnly=true)
public Route route(String container) {
var flows=store.container(container).stream().filter(s->!s.isRetired()).map(AppointmentWorkState::getFlow).filter(f->f!=null && f!=Flow.UNKNOWN).distinct().toList();
if(flows.size()>1) throw AppointmentWorkPolicy.conflict("柜次持久路线冲突,不能执行原业务写入");
return new Route(flows.isEmpty()?Flow.UNKNOWN:flows.get(0));
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.appointmentwork;
import lombok.Data;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
/** Work-owned state only. observedFingerprint is an audit baseline, never a second business plan. */
@Data
public class AppointmentWorkState {
private String workItemKey;
private int version;
private Type type;
private Flow flow;
private String containerBookingId;
private String containerNo;
private String requirementKey;
private String batchKey;
private String batchNo;
private String tripKey;
private List<String> batchKeys = new ArrayList<>();
private List<ContainerReference> containers = new ArrayList<>();
private String destinationKind;
private String originName;
private String destinationName;
private String timezone;
private String sourceVersion;
private Slot target;
private boolean bookingRequired = true;
private String receivingRequirements;
private String requirementReason;
private Owner owner;
private LocalDate followupDate;
private boolean reviewRequired;
private int reviewVersion;
private String reviewReason;
private String observedFingerprint;
private String observedImpactToken;
private boolean observedArranged;
private Responsibility responsibility;
private ReturnHandoff returnHandoff;
private boolean retired;
private String retirementReason;
private List<String> replacementKeys = new ArrayList<>();
private List<String> prerequisites = new ArrayList<>();
private List<BusinessReference> references = new ArrayList<>();
private List<SourceReference> sources = new ArrayList<>();
private List<String> readPermissions = new ArrayList<>();
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkStore {
public static final int MAX_ITEMS=10000;
private final AppointmentWorkMapper mapper;
private final ObjectMapper json;
public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
public AppointmentWorkMapper.Guard lock() {
var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard;
}
public String requireInitialized() {
var guard=mapper.guard();
if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项");
if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单");
return guard.getScopeVersion();
}
public void checkpoint(AppointmentWorkMapper.Guard guard) { mapper.saveGuard(guard); }
public List<AppointmentWorkState> all() {
var rows=mapper.all(MAX_ITEMS+1);
if(rows.size()>MAX_ITEMS) throw conflict("工作事项超过当前全量核对上限,不能返回截断统计");
return rows.stream().map(this::decode).toList();
}
public List<AppointmentWorkState> container(String id) { return all().stream().filter(s->id.equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->id.equals(c.containerBookingId()))).toList(); }
public AppointmentWorkState get(String key) {
var row=mapper.get(key); if(row==null) throw missing("事项不存在"); return decode(row);
}
public void insert(AppointmentWorkState state) { mapper.insert(row(state)); }
public void save(AppointmentWorkState state) {
state.setVersion(state.getVersion()+1);
if(mapper.update(row(state))!=1) throw conflict("事项版本已变化,请刷新后处理");
}
public AppointmentWorkMapper.History replay(String key,String commandKey) { return mapper.command(key,commandKey); }
public void history(AppointmentWorkState state,Actor actor,String action,String summary,String commandKey,String hash,Object result) {
var row=new AppointmentWorkMapper.History(); row.setId(UUID.randomUUID().toString()); row.setWorkItemKey(state.getWorkItemKey());
row.setOperatorId(actor.id()); row.setOperatorName(actor.name()); row.setAction(action); row.setSummary(summary);
row.setCommandKey(commandKey); row.setRequestHash(hash); row.setResponseJson(result==null?null:write(result));
row.setOccurredAt(LocalDateTime.now(ZoneOffset.UTC).truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); mapper.insertHistory(row);
}
public List<HistoryEntry> history(String key) {
var rows=mapper.selectHistory(key);
if(rows.size()>1000) throw conflict("事项历史超过详情上限,需接入历史分页后查看");
return rows.stream().map(r->new HistoryEntry(r.getOccurredAt().toInstant(ZoneOffset.UTC),r.getOperatorName(),r.getAction(),r.getSummary())).toList();
}
public String write(Object value) { try { return json.writeValueAsString(value); } catch(Exception e) { throw new IllegalStateException("工作事项序列化失败",e); } }
public <T> T read(String value,Class<T> type) { try { return json.readValue(value,type); } catch(Exception e) { throw new IllegalStateException("工作事项记录读取失败",e); } }
private AppointmentWorkState decode(AppointmentWorkMapper.Row row) { return read(row.getStateJson(),AppointmentWorkState.class); }
private AppointmentWorkMapper.Row row(AppointmentWorkState state) {
var row=new AppointmentWorkMapper.Row(); row.setWorkItemKey(state.getWorkItemKey()); row.setContainerBookingId(state.getContainerBookingId());
row.setVersion(state.getVersion()); row.setRetired(state.isRetired()); row.setStateJson(write(state)); return row;
}
}
@@ -0,0 +1,217 @@
package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.*;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
public class AppointmentWorkSyncService {
private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ));
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway gateway;
public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; }
/** Called by trusted server orchestration, never by GET or by opening a particular container. */
@Transactional
public SyncResult synchronize(SyncRequest request) {
if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜");
var guard=store.lock();
if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对");
var page=gateway.discover(request.afterContainerBookingId(),request.size());
if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); }
else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试");
else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试");
int created=0; var ids=new HashSet<String>();
for(var source:page.containers()) {
validateSource(source);
if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次");
var existing=store.container(source.containerBookingId());
if(existing.isEmpty()) {
if(source.establishedFlow()==Flow.UNKNOWN) {
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++;
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size();
} else {
var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null);
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size();
else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) {
if(source.establishedFlow()!=Flow.UNKNOWN) {
var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size();
route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route);
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route);
}
}
}
}
// An existing row is never deleted because an order left the active source page.
// Empty return and other unfinished work continue to read their original authority.
var all=new ArrayList<>(store.all());
var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList());
reconcileTransport(all,facts);
all=new ArrayList<>(store.all());
facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList());
observe(all,facts,SYSTEM);
guard.setScanCursor(page.complete()?null:page.nextCursor());
if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); }
store.checkpoint(guard);
return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete());
}
static void validateSource(ContainerSource source) {
if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null)
throw unavailable("已开船来源缺少身份或真实开船依据");
if(source.timezone()!=null) zone(source.timezone());
var seen=new HashSet<String>(); var batches=new HashSet<String>();
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.timezone()!=null) zone(requirement.timezone());
}
}
/** Caller holds work guard and validates the route against current original facts. */
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
validateSource(source);
if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确");
var requirements=gateway.requirements(source,flow);
source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements);
validateSource(source);
if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单");
var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a));
var created=new ArrayList<AppointmentWorkState>();
for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created);
for(var requirement:source.requirements()) {
add(source,Type.DELIVERY,requirement,flow,known,created);
add(source,Type.TRANSPORT,requirement,flow,known,created);
}
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())));
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.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());
}
store.insert(state); store.history(state,actor,"GENERATED","根据已确认路线建立工作需求",null,null,null);
}
String generationContainerId=source.containerBookingId();
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();
// A mutable original dispatch can remove this container's cargo while keeping other containers.
// This scan is a complete authority manifest for this container, including its remaining obligations.
var retained=old.getContainers().stream().filter(c->!containerId.equals(c.containerBookingId())).toList();
if(old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:") && !retained.isEmpty()) {
old.setContainers(retained);
if(containerId.equals(old.getContainerBookingId())) { old.setContainerBookingId(retained.get(0).containerBookingId());old.setContainerNo(retained.get(0).containerNo()); }
var removedDependency=key(containerId,flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null);
old.setPrerequisites(old.getPrerequisites().stream().filter(k->!removedDependency.equals(k)).toList());
store.save(old);store.history(old,actor,"SOURCE_REMOVED","原排车已移除该柜实物,保留其他柜的当前关联",null,null,null);continue;
}
var replacements=requirements.stream().filter(r->r.replacesRequirementKeys().contains(old.getRequirementKey()))
.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.
if(replacements.isEmpty() && old.getBatchKey()!=null && old.getBatchKey().startsWith("DISPATCH:"))
replacements=requirements.stream().filter(r->"CUSTOMER".equals(r.destinationKind()) && r.sources().stream().anyMatch(old.getSources()::contains)).map(r->requirementKey(containerId,old.getType(),r.requirementKey(),r.batchKey())).distinct().toList();
if(replacements.isEmpty()) throw conflict("原需求离开有效清单但没有明确替代关系,不能隐藏剩余工作");
old.setRetired(true); old.setRetirementReason("BATCH_REPLACED"); old.setReplacementKeys(replacements); store.save(old); store.history(old,actor,"REPLACED","原预约域已提供完整替代需求",null,null,null);
}
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 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); }
else if(requirement!=null) {
if("BATCH_REPLACED".equals(old.getRetirementReason())) {
// The full authority manifest explicitly reopens a cancelled obligation/restored original appointment.
// Repartitioned batches never appear here: the provider excludes replacement-bearing originals.
old.setRetired(false); old.setRetirementReason(null); old.setReplacementKeys(List.of()); store.save(old);
}
if(old.getFlow()!=flow) throw conflict("同一批次不能混合中转与直送路线");
var containers=new ArrayList<>(old.getContainers());
boolean update=!Objects.equals(old.getBatchNo(),requirement.batchNo()) || !Objects.equals(old.getOriginName(),requirement.originName()) || !Objects.equals(old.getDestinationName(),requirement.destinationName())
|| !Objects.equals(old.getDestinationKind(),requirement.destinationKind()) || !Objects.equals(old.getReferences(),requirement.references()) || !Objects.equals(old.getSources(),requirement.sources()) || type==Type.DELIVERY && !Objects.equals(old.getTimezone(),requirement.timezone());
if(containers.stream().noneMatch(c->c.containerBookingId().equals(source.containerBookingId()))) {
containers.addAll(state.getContainers()); old.setContainers(List.copyOf(containers));
var dependencies=new LinkedHashSet<>(old.getPrerequisites()); dependencies.add(key(source.containerBookingId(),flow==Flow.TRANSFER?Type.WAREHOUSE_RECEIPT:Type.PICKUP,null));
old.setPrerequisites(List.copyOf(dependencies)); update=true;
}
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);
}
}
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());
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;
}
void observe(List<AppointmentWorkState> states,Map<String,Fact> facts,Actor actor) {
var affected=new LinkedHashSet<String>();
for(var state:states) {
if(state.isRetired()) continue;
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);
}
}
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 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);
store.history(state,actor,review?"IMPACT_OBSERVED":"BASELINE_OBSERVED",review?state.getReviewReason():"记录当前原业务基线,不生成执行事实",null,null,null);
}
}
}
static void expandDependents(String key,List<AppointmentWorkState> states,Set<String> affected) {
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;
for(var candidate:states) if(!candidate.isRetired() && effectiveKeys(candidate.getPrerequisites(),byKey).contains(current) && affected.add(candidate.getWorkItemKey())) queue.add(candidate.getWorkItemKey());
}
}
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()));
var liveCanonical=new HashSet<String>();
for(var entry:members.entrySet()) {
var group=entry.getValue(); group.sort(Comparator.comparing(AppointmentWorkState::getWorkItemKey));
String canonicalKey=key("TRIP:"+entry.getKey(),Type.TRANSPORT,null); liveCanonical.add(canonicalKey);
var canonical=all.stream().filter(s->s.getWorkItemKey().equals(canonicalKey)).findFirst().orElse(null);
boolean fresh=canonical==null;
if(fresh) { canonical=store.read(store.write(group.get(0)),AppointmentWorkState.class); canonical.setWorkItemKey(canonicalKey); canonical.setVersion(0); canonical.setTripKey(entry.getKey()); canonical.setRequirementKey(null); canonical.setBatchKey(null); canonical.setBatchNo(null); canonical.setReplacementKeys(List.of()); }
var containers=group.stream().flatMap(s->s.getContainers().stream()).distinct().sorted(Comparator.comparing(ContainerReference::containerBookingId)).toList();
var batches=group.stream().map(AppointmentWorkState::getBatchKey).filter(Objects::nonNull).distinct().sorted().toList();
var prerequisites=group.stream().flatMap(s->s.getPrerequisites().stream()).distinct().sorted().toList();
boolean changed=canonical.isRetired() || !containers.equals(canonical.getContainers()) || !batches.equals(canonical.getBatchKeys()) || !prerequisites.equals(canonical.getPrerequisites());
canonical.setContainers(containers); canonical.setBatchKeys(batches); canonical.setPrerequisites(prerequisites); canonical.setRetired(false); canonical.setRetirementReason(null);
canonical.setReferences(group.stream().flatMap(s->s.getReferences().stream()).distinct().toList());
canonical.setSources(group.stream().flatMap(s->s.getSources().stream()).distinct().toList());
if(fresh) store.insert(canonical); else if(changed) store.save(canonical);
for(var member:group) if(!member.isRetired() || !member.getReplacementKeys().equals(List.of(canonicalKey))) { member.setRetired(true); member.setRetirementReason("TRIP_LINKED"); member.setReplacementKeys(List.of(canonicalKey)); store.save(member); }
}
for(var state:all) {
if(state.getTripKey()!=null && !liveCanonical.contains(state.getWorkItemKey()) && !state.isRetired()) { state.setRetired(true); state.setRetirementReason("TRIP_UNLINKED"); store.save(state); }
if(state.getType()==Type.TRANSPORT && state.getTripKey()==null && "TRIP_LINKED".equals(state.getRetirementReason()) && facts.containsKey(state.getWorkItemKey()) && facts.get(state.getWorkItemKey()).tripKey()==null) { state.setRetired(false); state.setRetirementReason(null); state.setReplacementKeys(List.of()); store.save(state); }
}
// Keep requirement edges stable; query traversal follows retired transport replacement keys.
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.appointmentwork;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** Periodic full scan also compensates remote changes and process restarts; no GET performs writes. */
@Component
@EnableScheduling
public class AppointmentWorkSynchronizationJob {
private static final Logger log=LoggerFactory.getLogger(AppointmentWorkSynchronizationJob.class);
private final AppointmentWorkSyncService synchronization;
private String cursor;
public AppointmentWorkSynchronizationJob(AppointmentWorkSyncService synchronization) { this.synchronization=synchronization; }
@Scheduled(initialDelayString="${baiyue.appointment-work.sync-initial-delay-ms:30000}",fixedDelayString="${baiyue.appointment-work.sync-delay-ms:10000}")
public void run() {
try {
var result=synchronization.synchronize(new AppointmentWorkModels.SyncRequest(cursor,100));
cursor=result.complete()?null:result.nextCursor();
} catch(RuntimeException error) {
cursor=null;
log.warn("Appointment work synchronization incomplete; next scan restarts from first page: {}",error.getClass().getSimpleName());
}
}
}