Logo CJiwan
CJiwan
Architecture.NETClean ArchitectureAlgorithmesBlazor

Un moteur de planification sans base de données : le chemin critique dans ProjectsManager

Comment ProjectsManager calcule dates au plus tôt, dates au plus tard et chemin critique en Domain pur, testable sans EF Core ni navigateur — retour sur l'architecture en couches et l'algorithme d'ordonnancement.

Publié le
7 minutes de lecture

ProjectsManager est une plateforme de gestion de projet (WBS, dépendances entre tâches, planification, temps passé, reporting) construite en .NET Blazor Server. Ce qui m’intéresse ici n’est pas la liste des fonctionnalités, mais un choix d’architecture précis : le moteur de planification ne connaît ni base de données, ni framework web. Il vit entièrement dans la couche Domain, sous forme de fonctions pures.

Le découpage en couches

Le projet suit une Clean Architecture à quatre couches, où le sens des dépendances est vérifiable directement dans les fichiers .csproj :

Domain          ← ne référence rien
Application     ← référence Domain
Infrastructure  ← référence Application
Web             ← référence Infrastructure et Application

ProjectsManager.Domain ne dépend d’aucun package NuGet métier : pas d’Entity Framework, pas d’ASP.NET, pas de Blazor. Rien que du C# et du System. C’est ce qui permet d’écrire des tests unitaires sur la logique de planification sans monter de base de données ni de serveur — la moitié des 71 fichiers de tests du projet portent uniquement sur cette couche.

Le code WBS : une récursion sur les frères et sœurs

Chaque tâche appartient à une arborescence (lots, sous-lots, tâches), et affiche un code du type 1, 1.1, 1.1.2. Ce code n’est stocké nulle part : il est recalculé à la volée en parcourant les enfants d’un même parent, triés par leur ordre d’affichage.

public static class WbsCodeCalculator
{
    public static IReadOnlyDictionary<int, string> Compute<T>(IEnumerable<T> items)
        where T : IWbsNode
    {
        var byParent = items
            .GroupBy(i => i.ParentId ?? RootSentinel)
            .ToDictionary(g => g.Key, g => g.OrderBy(i => i.Order).ToList());

        var result = new Dictionary<int, string>();

        void Walk(int parentKey, string? prefix)
        {
            if (!byParent.TryGetValue(parentKey, out var siblings)) return;

            for (var i = 0; i < siblings.Count; i++)
            {
                var code = prefix is null ? $"{i + 1}" : $"{prefix}.{i + 1}";
                result[siblings[i].Id] = code;
                Walk(siblings[i].Id, code);
            }
        }

        Walk(RootSentinel, null);
        return result;
    }
}

Rien d’exotique, mais c’est révélateur de l’esprit du code : une règle de présentation qui pourrait sembler « côté écran » est en réalité une règle métier (l’ordre des tâches dans un lot a un sens), donc elle vit dans le Domain.

Le vrai morceau : l’ordonnanceur par chemin critique

La partie la plus dense du projet est ProjectScheduler, qui implémente la méthode du chemin critique (CPM) — le même principe que Microsoft Project ou Primavera, mais réduit à ce dont l’application a réellement besoin.

L’algorithme se déroule en trois temps :

  1. Tri topologique (algorithme de Kahn) sur le graphe de dépendances, pour ordonner les tâches sans jamais traiter un successeur avant son prédécesseur. Une file de priorité garantit un résultat stable : deux appels sur les mêmes données doivent toujours produire le même planning.
  2. Passe avant : calcul des dates au plus tôt (early start / early finish), en propageant chaque contrainte de dépendance le long du graphe.
  3. Passe arrière : calcul des dates au plus tard (late start / late finish), à rebours, pour en déduire la marge (float) de chaque tâche.
public static ProjectSchedule Compute(
    IEnumerable<WorkItem> workItems,
    IEnumerable<WorkItemDependency> dependencies,
    IWorkCalendar calendar)
{
    // ... filtrage des tâches datées, construction du graphe ...

    if (DependencyGraph.FindCycle(edges) is { } cycle)
        return ProjectSchedule.Cyclic(cycle);

    var order = TopologicalOrder(items.Keys, edges);

    // Passe avant : chaque tâche part de sa date prévue et se fait
    // repousser par ses prédécesseurs, jamais tirer en arrière.
    foreach (var id in order)
    {
        var anchor = DependencyConstraint.Anchor(dependency.Type, earlyStart[...], earlyFinish[...]);
        var earliest = DependencyConstraint.EarliestAllowed(dependency.Type, anchor, lag, calendar);
        // ...
    }

    // Passe arrière : symétrique, à rebours de l'ordre topologique.
    // ...
}

Trois décisions de conception méritent d’être soulignées :

  • La fonction ne modifie rien. ProjectScheduler.Compute est un pur calcul qui rend un planning candidat ; c’est l’appelant qui décide de l’appliquer. Cela permet à l’écran d’afficher « voici ce qui bougerait » avant que quoi que ce soit ne bouge réellement — et ça rend l’algorithme testable en isolation totale.
  • Les durées ne se compressent jamais. Replanifier déplace une tâche dans le temps, mais ne raccourcit jamais sa durée pour tenir une contrainte. Un ordonnanceur qui compresserait produirait un planning cohérent sur le papier, mais impossible à tenir dans les faits.
  • Tout se compte en jours ouvrés, via un IWorkCalendar injecté (semaine travaillée paramétrable + jours fériés). Une marge qui enjamberait un week-end sans cette abstraction afficherait des jours de battement qui n’existent pas.

Quatre types de dépendances, et une détection de cohérence séparée

Le modèle de dépendances reprend le standard de la gestion de projet : Fin→Début, Début→Début, Fin→Fin, Début→Fin (DependencyType). Un DependencyCoherenceChecker distinct de l’ordonnanceur vérifie, tâche par tâche, si les dates actuellement saisies respectent ces contraintes — et si non, calcule directement la date corrigée à proposer à l’utilisateur. Ordonnanceur et vérificateur de cohérence partagent la même fonction de calcul d’ancrage (DependencyConstraint), pour ne jamais avoir deux définitions divergentes de la même règle.

L’agrégation : remonter l’avancement des feuilles vers les lots

Un lot n’a pas de dates propres : les siennes sont l’enveloppe de ses enfants. WorkItemRollupCalculator remonte récursivement dates, avancement, charge estimée et temps passé depuis les tâches feuilles jusqu’aux lots racines, avec une règle simple mais non triviale : l’avancement d’un lot est pondéré par la charge estimée de ses enfants, pas moyenné à plat — sans quoi un lot contenant une tâche non chiffrée afficherait un avancement trompeur.

Cette fonction est appelée aussi bien par l’écran de planification que par le tableau de bord de reporting : le calcul n’existe qu’à un seul endroit, pour que le Gantt et les indicateurs du dashboard racontent toujours la même histoire.

Des refus explicites plutôt que des exceptions

Dernier détail d’architecture, côté Application cette fois : au lieu d’un Result<T> générique ou d’exceptions pour signaler qu’une opération est refusée (par exemple, déplacer une tâche sous un parent qui est l’un de ses propres descendants), chaque service applicatif définit son propre type de résultat :

public enum StructureRejection
{
    None, NotAllowed, UnknownItem, UnknownParent,
    ParentIsNotAPackage, ParentIsInItsOwnSubtree
}

public readonly record struct StructureResult(StructureRejection Rejection)
{
    public bool Succeeded => Rejection == StructureRejection.None;
}

La distinction est volontaire : un refus (l’utilisateur a demandé quelque chose que le modèle n’admet pas) n’est pas un bug de programmation, et les deux ne doivent pas emprunter le même canal.

Ce que ça donne à l’usage

Rien de tout ça n’est visible dans l’interface — l’utilisateur voit un Gantt (rendu par Radzen.Blazor, alimenté par les résultats du scheduler) qui se replanifie proprement quand une date bouge. Mais c’est précisément parce que le calcul est isolé du framework web et de la base de données qu’il peut être testé de façon exhaustive, indépendamment de tout le reste : un changement dans la couche Web ou dans le schéma de base ne peut pas silencieusement casser la logique de chemin critique.

C’est la promesse habituelle de la Clean Architecture, mais appliquée ici à quelque chose de concret et non trivial : un vrai algorithme d’ordonnancement, pas une simple CRUD déguisée en couches.