You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于OptaPlanner的项目作业与任务分配调度器实现问询

混合项目作业与任务分配调度的OptaPlanner实现问题

需求背景

我正在构建一个REST驱动的调度器,需处理N个带有起止时间约束的项目,每个项目包含预定义执行顺序的N个任务。核心目标分为两部分:

  • 确定各任务的起止日期,需考虑预期时长、延迟时长及后续任务的时间缓冲;
  • 完成时间调度后,基于部门、所需技能为每个任务分配最优资源,同时平衡整体工作量。

当前实现思路

我计划采用**项目作业调度(PJ)+任务分配调度(TA)**的混合方案,分两步求解:

  1. 先处理PJ部分,确定所有任务的起止时间;
  2. 将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");
            });
        }
}

疑问与寻求建议

  1. 我当前分两步求解的思路是否正确?
  2. Risorsa(资源)类是否需要标记为@PlanningEntity?
  3. 针对这种混合调度问题,有哪些实现技巧或建议?

使用技术栈: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)分数约束设计

  • 硬约束:必须满足的规则,包括:
    • 任务依赖:前置任务未完成,后置任务不能开始;
    • 资源技能匹配:任务所需技能必须在资源的技能列表中;
    • 资源负载排他:同一资源不能在同一时间执行多个任务;
    • 项目起止时间:所有任务的时间范围不能超出项目的起止约束。
  • 软约束:优化目标,包括:
    • 资源工作量平衡:最小化各资源任务总时长的方差;
    • 减少任务延迟
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 23:55:24