基于OptaPlanner的项目作业与任务分配调度器实现问询
混合项目作业与任务分配调度的OptaPlanner实现问题
需求背景
我正在构建一个REST驱动的调度器,需处理N个带有起止时间约束的项目,每个项目包含预定义执行顺序的N个任务。核心目标分为两部分:
- 确定各任务的起止日期,需考虑预期时长、延迟时长及后续任务的时间缓冲;
- 完成时间调度后,基于部门、所需技能为每个任务分配最优资源,同时平衡整体工作量。
当前实现思路
我计划采用**项目作业调度(PJ)+任务分配调度(TA)**的混合方案,分两步求解:
- 先处理PJ部分,确定所有任务的起止时间;
- 将PJ的结果作为ProblemFacts传入TA部分,完成资源分配。
已定义核心类的注解如下:
SchedulerPJ标记为@PlanningSolution;Progetto(项目)标记为@ProblemFact;Task标记为@PlanningEntity,包含startDate和resource作为@PlanningVariable。
初始核心代码
@PlanningSolution public class SchedulerPJ { //ATTRIBUTES @PlanningId private UUID id; @ProblemFactCollectionProperty private List<Progetto> progetti; @PlanningEntityCollectionProperty @ValueRangeProvider(id = "taskRange") private List<Task> tasks; @PlanningScore private HardSoftScore score; .... } @PlanningEntity(difficultyComparatorClass = TaskDifficultyComparator.class) public class Task { //ENUMS enum Priorita { BASSA, MEDIA, ALTA, CRITICA, } //ATTRIBUTES @JsonIgnore private Progetto progetto; private String nomeTask; private List<OrdineDiEsecuzione> ordineDiEsecuzione; private List<Task> listaTaskSuccessive; @PlanningId private long id; private long idTaskPadre; private String richiestaCompetenza; //Percentuale di completamento della TASK private Integer percentualeCompletamento; //Priorità della TASK private Priorita priorita; private boolean bloccato = false; private Integer ritardo; private Integer buffer; //private Integer durataOre; private Integer durataGiorni; //Planning Variables: cambia durante la pianificazione, tra gli scores calcolati @InverseRelationShadowVariable(sourceVariableName = "tasks") private Risorsa risorsa; @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd") @ShadowVariable(variableListenerClass = DataInizioUpdateVariableListener.class, sourceEntityClass = Risorsa.class, sourceVariableName = "tasks") private LocalDate dataInizio; @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd") private LocalDate dataFine; }
更新后的代码(UPDATE 1)
Task类
public class Task { //ATTRIBUTES @PlanningId private long id; @JsonIgnore private Progetto progetto; private long idProgetto; @JsonProperty("richiestaCompetenza") private String competenzaRichiesta; private Integer percentualeCompletamento; private Priorita priorita; private String tipoDipendenza; @PlanningPin private boolean bloccato; @JsonProperty("durataRitardo") private Integer durataRitardo; @JsonProperty("durataBuffer") private Integer durataBuffer; private Integer durataGiorni; @PlanningVariable(valueRangeProviderRefs = {"risorsaRange"}) private Risorsa risorsa; @JsonIgnore private Task taskPrecedente; @JsonProperty("idTaskPrecedente") private Long idTaskPrecedente; @JsonIgnore private Task taskSuccessiva; @JsonProperty("idTaskSuccessiva") private Long idTaskSuccessiva; @ShadowVariable(variableListenerClass = PredecessorDoneDateUpdatingVariableListener.class, sourceVariableName = "risorsa") public LocalDate dataInizioBuffer; @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd") @PiggybackShadowVariable(shadowVariableName = "dataInizioBuffer") private LocalDate dataFine; @JsonFormat(shape=JsonFormat.Shape.STRING, pattern="yyyy-MM-dd") @PiggybackShadowVariable(shadowVariableName = "dataInizioBuffer") private LocalDate dataInizio; private LocalDate dataDiFineIdeale; }
Risorsa(资源)类
public class Risorsa { //ATTRIBUTES @PlanningId private long id; @JsonProperty("nome") private String nome; @JsonProperty("competenze") private List<String> listaCompetenze; private List<String> reparto; private String turnoDiLavoro; private List<LocalDate> giorniDiFerie; private List<Permessi> oreDiPermesso; private boolean occupato; private TurnoDiLavoro turnoDiLavoroSettimanale; private boolean isRinnovabile; private boolean isLocale; private List<CalendarioLavorativo> calendarioLavorativo; private List<CaricoDiLavoro> caricoDiLavoro; //new @JsonIgnore @InverseRelationShadowVariable(sourceVariableName = "risorsa") private List<Task> tasks = new ArrayList<>(); }
变量监听器类
public class PredecessorDoneDateUpdatingVariableListener implements VariableListener<SchedulerPJ, Task> { @Override public void beforeVariableChanged(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { } @Override public void afterVariableChanged(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { updateDataInizio(scoreDirector, task); } @Override public void beforeEntityAdded(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { } @Override public void afterEntityAdded(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { updateDataInizio(scoreDirector, task); } @Override public void beforeEntityRemoved(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { } @Override public void afterEntityRemoved(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { updateDataInizio(scoreDirector, task); } private void updateDataInizio(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { Task taskPrecedente = task.getTaskPrecedente(); Task taskSuccessiva = task.getTaskSuccessiva(); LocalDate dataInizioBuffer; LocalDate dataInizio; if (taskPrecedente == null) { dataInizioBuffer = task.getDataInizioBuffer(); dataInizio = task.getProgetto().getDataInizio(); } else { dataInizioBuffer = taskPrecedente.getDataFine(); dataInizio = taskPrecedente.getDurataBuffer() > 0 ? dataInizioBuffer.plusDays(taskPrecedente.getDurataBuffer()) : dataInizioBuffer; } for (Task shadowTaskCorrente = task; shadowTaskCorrente != null && (!Objects.equals(shadowTaskCorrente.getDataInizioBuffer(), dataInizioBuffer) || !Objects.equals(shadowTaskCorrente.getDataInizio(), dataInizio));) { scoreDirector.beforeVariableChanged(shadowTaskCorrente, "dataInizioBuffer"); shadowTaskCorrente.setDataInizioBuffer(dataInizioBuffer); scoreDirector.afterVariableChanged(shadowTaskCorrente, "dataInizioBuffer"); scoreDirector.beforeVariableChanged(shadowTaskCorrente, "dataInizio"); // Se la dataInizio è un fine settimana (sabato o domenica), salta al prossimo lunedì assert dataInizio != null; if (dataInizio.getDayOfWeek() == DayOfWeek.SATURDAY) { dataInizio = dataInizio.plusDays(2); // Salta il sabato e la domenica } else if (dataInizio.getDayOfWeek() == DayOfWeek.SUNDAY) { dataInizio = dataInizio.plusDays(1); // Salta la domenica } shadowTaskCorrente.setDataInizio(dataInizio); scoreDirector.afterVariableChanged(shadowTaskCorrente, "dataInizio"); scoreDirector.beforeVariableChanged(shadowTaskCorrente, "dataFine"); shadowTaskCorrente.setDataFine(getProssimaDataLavorativa(dataInizio, shadowTaskCorrente.getDurataGiorni())); scoreDirector.afterVariableChanged(shadowTaskCorrente, "dataFine"); LocalDate endDateTime = shadowTaskCorrente.getDataFine(); taskPrecedente = shadowTaskCorrente; //Imposto la task precedente con la task attuale shadowTaskCorrente = shadowTaskCorrente.getTaskSuccessiva() != null ? shadowTaskCorrente.getTaskSuccessiva() : null ; //Imposto la task attuale con la task successiva if (shadowTaskCorrente == null || endDateTime == null) { dataInizioBuffer = null; dataInizio = null; } else { dataInizioBuffer = endDateTime; dataInizio = taskPrecedente.getDurataBuffer() > 0 ? dataInizioBuffer.plusDays(taskPrecedente.getDurataBuffer()) : dataInizioBuffer; if (dataInizio.getDayOfWeek() == DayOfWeek.SATURDAY) { dataInizio = dataInizio.plusDays(2); // Salta il sabato e la domenica } else if (dataInizio.getDayOfWeek() == DayOfWeek.SUNDAY) { dataInizio = dataInizio.plusDays(1); // Salta la domenica } shadowTaskCorrente.setDataInizio(dataInizio); Log.info("Task precedente: " + taskPrecedente.getId() + " (Inizio: " + taskPrecedente.getDataInizio() + " | Fine: " + taskPrecedente.getDataFine() + " | Fine ideale: " + taskPrecedente.getDataDiFineIdeale() + " | Durata Prevista: " + taskPrecedente.getDurataGiorni() + " | Durata Reale: " + Period.between(taskPrecedente.getDataInizio(), taskPrecedente.getDataFine()).getDays() + " | Buffer: " + taskPrecedente.getDurataBuffer() + ")" + " Task corrente: " + shadowTaskCorrente.getId() + " (Inizio: " + shadowTaskCorrente.getDataInizio() + " | Fine: " + shadowTaskCorrente.getDataFine() + " | Fine ideale: " + shadowTaskCorrente.getDataDiFineIdeale() + " | Durata Prevista: " + shadowTaskCorrente.getDurataGiorni() + " | Durata Reale: " + Period.between(taskPrecedente.getDataInizio(), taskPrecedente.getDataFine()).getDays() + " | Buffer: " + shadowTaskCorrente.getDurataBuffer() + ")"); if(taskPrecedente.getRisorsa() != null){ updateCaricoDiLavoro(scoreDirector, taskPrecedente); } } } } private LocalDate getProssimaDataLavorativa(LocalDate data, int giorniDaAggiungere){ LocalDate dataLavorativa = data; for(int i = 0; i < giorniDaAggiungere; i++){ dataLavorativa = dataLavorativa.plusDays(1); // Se la data è un fine settimana (sabato o domenica), salta al prossimo lunedì if (dataLavorativa.getDayOfWeek() == DayOfWeek.SATURDAY) { dataLavorativa = dataLavorativa.plusDays(2); // Salta il sabato e la domenica } else if (dataLavorativa.getDayOfWeek() == DayOfWeek.SUNDAY) { dataLavorativa = dataLavorativa.plusDays(1); // Salta la domenica } } return dataLavorativa; } private void updateCaricoDiLavoro(ScoreDirector<SchedulerPJ> scoreDirector, Task task) { Risorsa risorsa = task.getRisorsa(); if (risorsa == null) { return; } LocalDate dataInizio = task.getDataInizio(); LocalDate dataFine = task.getDataFine(); int durataGiorni = task.getDurataGiorni(); for (CalendarioLavorativo calendarioLavorativo : risorsa.getCalendarioLavorativo()) { LocalDate dataInizioSettimana = calendarioLavorativo.getDataInizioSettimana(); LocalDate dataFineSettimana = calendarioLavorativo.getDataFineSettimana(); //Data di Inzio e data di Fine della Task ricadono nella stessa settimana if((dataInizio.isAfter(dataInizioSettimana) || dataInizio.isEqual(dataInizioSettimana)) && (dataFine.isBefore(dataFineSettimana) || dataFine.isEqual(dataFineSettimana))){ int numeroSettimana = calendarioLavorativo.getNumeroSettimana(); risorsa.getCaricoDiLavoro().stream().filter(caricoDiLavoro -> caricoDiLavoro.getNumeroSettimana() == numeroSettimana).findFirst().ifPresent(caricoDiLavoro -> { scoreDirector.beforeVariableChanged(task, "risorsa"); caricoDiLavoro.setCaricoDiLavoroSettimanale(caricoDiLavoro.getCaricoDiLavoroSettimanale() + colcalaCaricoDilavoro(durataGiorni)); scoreDirector.afterVariableChanged(task, "risorsa"); }); } }
疑问与寻求建议
- 我当前分两步求解的思路是否正确?
Risorsa(资源)类是否需要标记为@PlanningEntity?- 针对这种混合调度问题,有哪些实现技巧或建议?
使用技术栈:OptaPlanner 9.44.0 Final、Quarkus
解答
1. 分两步求解思路的合理性
分两步求解的思路是可行且易维护的,但存在局限性:
- 适合场景:如果资源约束(如技能匹配、资源可用性)不会大幅改变任务的时间计划,分两步能降低求解复杂度,提升调试效率。
- 局限性:如果资源分配会直接影响任务时间(比如资源休假导致任务延迟,进而连锁影响后续任务),分两步会得到次优解——因为PJ阶段没有考虑资源约束,TA阶段调整资源可能导致时间冲突,需要额外处理。
- 优化方向:若资源对时间影响较大,建议合并为单求解器,同时优化任务的时间分配和资源分配,让OptaPlanner在求解过程中同时平衡两类约束。
2. Risorsa类是否需要标记为@PlanningEntity
不需要。Risorsa属于问题事实(ProblemFact):
- 它是调度的候选输入,本身的属性(如技能、假期)不需要被OptaPlanner修改;
- 你要优化的是
Task的risorsa变量(即任务分配给哪个资源),而非资源本身; - 当前代码中
@InverseRelationShadowVariable在Risorsa的tasks字段上是正确的,用来跟踪资源被分配的任务列表,辅助分数计算(比如资源负载)。
3. 实现技巧与建议
(1)单求解器与分求解器的选择
- 单求解器方案:将
Task的risorsa(资源分配)和dataInizio(开始时间)都作为规划变量,通过变量监听器关联两者(比如资源的假期会影响任务的实际执行天数)。这种方案能得到更优的全局解,但复杂度更高,需要设计更完善的分数约束。 - 分求解器方案:保留当前思路,但需在TA阶段添加时间校验约束,确保资源分配后任务时间仍符合依赖要求,若冲突则触发局部时间调整。
(2)变量监听器优化
- 确保所有影子变量的修改都通过
ScoreDirector的beforeVariableChanged和afterVariableChanged方法包裹,避免分数计算错误; - 将工作日计算、资源日历校验封装为工具类,避免重复代码;
- 变量监听器中处理任务依赖时,要考虑链式影响(比如修改一个前置任务的时间,需递归更新所有后续任务),当前代码的循环处理逻辑是正确的,但要注意边界条件(如无后续任务时的终止)。
(3)分数约束设计
- 硬约束:必须满足的规则,包括:
- 任务依赖:前置任务未完成,后置任务不能开始;
- 资源技能匹配:任务所需技能必须在资源的技能列表中;
- 资源负载排他:同一资源不能在同一时间执行多个任务;
- 项目起止时间:所有任务的时间范围不能超出项目的起止约束。
- 软约束:优化目标,包括:
- 资源工作量平衡:最小化各资源任务总时长的方差;
- 减少任务延迟
相关产品推荐
相关产品推荐

