Planningsversiebeheer als Git: snapshots en goedkeuringsflows

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.