feat(appointment): integrate work calendar and stable cargo batches
This commit is contained in:
+16
@@ -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("工作事项同步守卫未初始化");
|
||||
}
|
||||
}
|
||||
+161
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
@@ -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());
|
||||
}
|
||||
}
|
||||
+49
@@ -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());
|
||||
}
|
||||
}
|
||||
+43
@@ -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()); }
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+112
@@ -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("内部服务身份未配置"); }
|
||||
}
|
||||
+43
@@ -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); }
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+25
@@ -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));
|
||||
}
|
||||
}
|
||||
+67
@@ -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);
|
||||
}
|
||||
+75
@@ -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";
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+164
@@ -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); }
|
||||
}
|
||||
+98
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
@@ -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));
|
||||
}
|
||||
}
|
||||
+49
@@ -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<>();
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+217
@@ -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.
|
||||
}
|
||||
}
|
||||
+27
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user