← Terug naar blog
AI Engineering
28 oktober 2025

Planningsversiebeheer als Git: snapshots en goedkeuringsflows

Hoe je een planningsysteem bouwt met een OPEN → APPROVED → REVERTED lifecycle — inclusief solver-integratie en onveranderlijke historie
Aron Heesakkers
Aron HeesakkersFullstack Developer & AI Engineer

Een planning die gewijzigd wordt, verliest zijn geschiedenis. Wie heeft wat veranderd? Wat was de roostering vorige week? Hoe herstel je een versie die twee weken geleden werkte?

De oplossing is versiebeheer: elke planningsversie is een immutable snapshot. De huidige versie heet de HEAD. Goedkeuren maakt een versie definitief. Terugdraaien maakt een nieuw snapshot op basis van een oudere staat.

Dat is precies hoe Git werkt — en het is precies hoe we het planningssysteem hebben gebouwd.

De versie-lifecycle

Een planningsversie heeft drie staten:

OPEN → APPROVED → (archief)
          ↑
       REVERTED (nieuwe APPROVED versie op basis van oudere staat)

OPEN is de werkversie: de planner is bezig. APPROVED is definitief: medewerkers kunnen worden geïnformeerd, facturen kunnen worden aangemaakt. REVERTED is een speciaal geval — niet een rollback van de database, maar het aanmaken van een nieuwe APPROVED-versie op basis van een oudere snapshot.

De Planning-entiteit heeft een headVersionId die altijd naar de actieve versie wijst:

model Planning {
  id            String            @id
  headVersionId String?
  headVersion   PlanningVersion?  @relation("HeadVersion", fields: [headVersionId])
  versions      PlanningVersion[]
}

model PlanningVersion {
  id        String              @id
  planningId String
  status    PlanningVersionStatus // OPEN | APPROVED
  snapshot  Json                 // volledige staat op moment van goedkeuring
  createdAt DateTime
  createdBy String              // wie heeft deze versie gemaakt
}

Het snapshot-veld slaat de volledige planning op op het moment van goedkeuring. Dat is de onveranderlijke bron: ook als er later shifts worden verwijderd of medewerkers worden gewijzigd, blijft het snapshot intact.

Goedkeuren

approveVersion loopt in een $transaction om te garanderen dat de status-update en de HEAD-update atomair zijn:

async approveVersion(planningId: string, versionId: string, userId: string): Promise<void> {
  await this.db.$transaction(async (tx) => {
    const version = await tx.planningVersion.findUniqueOrThrow({
      where: { id: versionId, planningId, status: 'OPEN' },
    })

    // Snapshot aanmaken van de huidige staat
    const snapshot = await this.buildSnapshot(planningId, tx)

    await tx.planningVersion.update({
      where: { id: versionId },
      data: {
        status: 'APPROVED',
        snapshot,
        approvedAt: new Date(),
        approvedBy: userId,
      },
    })

    // HEAD bijwerken
    await tx.planning.update({
      where: { id: planningId },
      data: { headVersionId: versionId },
    })
  })

  await this.eventBus.emit(new PlanningVersionApprovedEvent(planningId, versionId))
}

De snapshot wordt gebouwd op het moment van goedkeuring — niet eerder, zodat last-minute wijzigingen meegenomen worden.

Terugdraaien: nieuwe commit op basis van oud snapshot

Terugdraaien is niet een rollback in de database. Het is het aanmaken van een nieuwe APPROVED-versie waarvan het snapshot gelijk is aan die van een oudere versie. Dat is dezelfde semantiek als git revert: de geschiedenis blijft intact, er komt een nieuwe commit bovenop.

async revertVersion(planningId: string, targetVersionId: string, userId: string): Promise<void> {
  await this.db.$transaction(async (tx) => {
    const targetVersion = await tx.planningVersion.findUniqueOrThrow({
      where: { id: targetVersionId, planningId },
    })

    // Herstel de shifts naar de staat van het target-snapshot
    await this.restoreFromSnapshot(planningId, targetVersion.snapshot, tx)

    // Maak een nieuwe APPROVED versie aan als bewijs van de revert
    const newVersion = await tx.planningVersion.create({
      data: {
        planningId,
        status: 'APPROVED',
        snapshot: targetVersion.snapshot,
        revertedFromId: targetVersionId,
        approvedAt: new Date(),
        approvedBy: userId,
      },
    })

    await tx.planning.update({
      where: { id: planningId },
      data: { headVersionId: newVersion.id },
    })
  })
}

revertedFromId legt de herkomst vast: in de versiegeschiedenis is zichtbaar dat versie 5 een revert is van versie 2. Dat is het audit-equivalent van git revert vs git reset --hard.

Solver-integratie

De planner integreert met een externe solver die optimale roosterverdeling berekent. Als de solver een nieuw resultaat oplevert, moet de huidige OPEN-versie worden gesloten en een nieuwe versie worden aangemaakt:

async createPlanningVersionFromSolverResult(
  planningId: string,
  solverResult: SolverResult,
): Promise<PlanningVersion> {
  return this.db.$transaction(async (tx) => {
    const planning = await tx.planning.findUniqueOrThrow({
      where: { id: planningId },
      include: { headVersion: true },
    })

    // Sluit de HEAD als die nog OPEN is
    if (planning.headVersion?.status === 'OPEN') {
      await tx.planningVersion.update({
        where: { id: planning.headVersion.id },
        data: { status: 'APPROVED', closedAt: new Date() },
      })
    }

    // Pas de shifts aan op basis van solver-output
    await this.applyShiftChanges(planningId, solverResult.assignments, tx)

    // Maak nieuwe OPEN versie aan voor de planner om te reviewen
    const newVersion = await tx.planningVersion.create({
      data: {
        planningId,
        status: 'OPEN',
        source: 'solver',
        solverRunId: solverResult.runId,
      },
    })

    await tx.planning.update({
      where: { id: planningId },
      data: { headVersionId: newVersion.id },
    })

    return newVersion
  })
}

De solver-versie start als OPEN: de planner moet hem eerst bekijken en goedkeuren. Dat is een bewuste keuze — volledig automatisch goedkeuren zou de menselijke controle uit het proces halen.

Versiegeschiedenis als audit-log

Met immutable snapshots en revertedFromId-links heb je een volledige versiegeschiedenis:

v1 (APPROVED, 2025-10-01, planner: Aron) — initiële planning
v2 (APPROVED, 2025-10-03, source: solver) — solver-optimalisatie
v3 (APPROVED, 2025-10-05, revertedFrom: v1) — teruggedraaid naar v1
v4 (OPEN, 2025-10-07) — huidige werkversie

Die geschiedenis beantwoordt elke vraag die een klant of auditor kan stellen: wat was de planning op dag X, wie heeft goedgekeurd, en wanneer.

Het snapshot-patroon heeft één nadeel: opslag. Een volledige planning met 200 shifts, 50 medewerkers en alle metadata is al snel 50–100 KB als JSON. Bij 10 versies per week is dat 500 KB per planning per week. Voor de meeste applicaties is dat verwaarloosbaar; bij tienduizenden planningen per organisatie wil je overwegen om alleen de diff op te slaan in plaats van het volledige snapshot.


Project bespreken?

Heb je een use case waar je over twijfelt?

We praten graag over of een AI-aanpak past — en zeggen het eerlijk als het beter zonder kan. Vaste prijs, afgesproken scope.
Plan een gesprek
Probeer AI