Process automation
Een dispatch sheet van 1.800 rijen vervangen: het playbook
06:40 op een dinsdag. Het werkbestand van een dispatcher is gelocked door een nachtelijke sessie, zes trucks komen er bijna aan, en het bestand met de hele operatie opent niet.

06:40 op een dinsdag. De ochtenddispatcher opent DISPATCH_LIVE_v47_FINAL_USE_THIS.xlsx op de gedeelde schijf en krijgt de dialoog die ze al duizend keer heeft gezien: bestand vergrendeld door een andere gebruiker. Iemand in het magazijn heeft het 's nachts open laten staan. Zes trucks komen de komende veertig minuten binnenrijden. Tot die lock weg is, heeft geen van hen een bevestigde bay.
Dit is de expediteur die ons inhuurde om de sheet te slopen. 1.800 actieve rijen, 23 kolommen, 14 verborgen hulpkolommen, elf dispatchers verdeeld over twee kantoren, drie pivot tables waar niemand aan durfde te komen omdat de laatste die het probeerde de conditional formatting een week lang had gesloopt. Het werkboek was het systeem. Het was ook de bottleneck, het audit log, de database met klantbeloftes, en het single point of failure.
Wij hebben het vervangen door een Next.js board, een Postgres job queue, en een nightly reconciliatie-script. De migratie duurde elf weken, van de eerste audit tot het uitfaseren van de oude sheet. Hier is het playbook, in de volgorde waarin we het uitvoerden.
Map de sheet voordat je het schema aanraakt
De eerste reflex, als iemand je een gigantische Excel overhandigt, is om de kolomkoppen te lezen en meteen een Prisma schema te beginnen schrijven. Niet doen. De koppen liegen. Het echte schema zit in wat de dispatchers dóen, niet in wat IT in 2017 heeft opgeschreven.
We hebben zes uur op een screen-share gezeten met de senior dispatcher. Niet gevraagd wat de kolommen betekenden. Wel gevraagd om door het werkboek te lopen zoals ze het echt gebruikte. Bij uur drie hadden we al gevonden:
- Een statuskolom met 14 gedocumenteerde waardes en 31 geobserveerde waardes, waarvan sommige alleen verschilden in een spatie aan het eind.
- Eén kolom genaamd
REMARKSdie stiekem ETA-overrides, terugbeltijden van klanten en een vlag voor "niet laden vóór 14:00" opsloeg alsDNL14. - Een verborgen hulpkolom die de echte klantnaam ophaalde via een VLOOKUP naar een apart werkboek op een netwerkshare.
- Twee kolommen die qua vorm altijd overeenkwamen (
Origin BAY,Origin BAY assigned) maar in 7% van de rijen verschilden, omdat de één werd ingevuld door de ochtenddispatcher en de ander door de poortwachter, en de poortwachter had gelijk.
Dat laatste punt is het hele verhaal. De sheet had geen enkele source of truth; hij had er twee, in conflict, en de business wist via sociale conventie welke te vertrouwen. Als je de koppen migreert, lever je het conflict mee. Als je het gedrag migreert, kun je het opsplitsen in twee velden en de conflict-resolution regel opschrijven.
Audit de werkwoorden (wat dispatchers doen met de sheet) voordat je naar de zelfstandige naamwoorden kijkt (hoe de kolommen heten). De werkwoorden zijn je schema. De zelfstandige naamwoorden zijn documentatie.
Schema op basis van geobserveerde states, niet gedocumenteerde
Toen we eenmaal de werkwoorden hadden, schreef het schema zich bijna vanzelf. Drie kerntabellen, twee referentietabellen, en een slank event log. De truc was om state te modelleren als een enum waar de database geen millimeter in afweek.
create type dispatch_status as enum (
'booked',
'awaiting_documents',
'ready_for_bay',
'at_bay',
'loading',
'loaded',
'departed',
'cancelled',
'on_hold'
);
create table shipment (
id uuid primary key default gen_random_uuid(),
ref text not null unique,
customer_id uuid not null references customer(id),
status dispatch_status not null default 'booked',
bay_id uuid references bay(id),
scheduled_at timestamptz not null,
departed_at timestamptz,
notes text,
version int not null default 0,
updated_at timestamptz not null default now(),
updated_by uuid not null references app_user(id)
);
create index shipment_status_scheduled_idx
on shipment (status, scheduled_at)
where status in ('booked','awaiting_documents','ready_for_bay');
Negen states, geen veertien. De andere vijf waren sub-states van on_hold met een reden-code. De partial index houdt de dispatch-board query onder de 5ms, omdat het board alleen geeft om de drie live statussen.
We hebben ook een regel afgedwongen die het werkboek nooit kon: elke statusovergang schrijft een rij naar een append-only event tabel met de oude state, de nieuwe state, de gebruiker en de timestamp. De eerste week van die log vertelde ons meer over hoe dispatch écht werkte dan de acht jaar werkboek-historie daarvoor.
Het board: Next.js met server actions en een strakke refresh loop
De UI is sober. Een verticale Kanban met één kolom per live status, een filter bovenin voor regio, en een rij per zending. Geen animaties op de drag handles. Dispatchers willen geen choreografie; ze willen dat de rij verspringt als ze "ready" klikken en daar blijft als het netwerk hapert.
We hebben het gebouwd op de Next.js App Router met server actions voor mutaties en een polling fetch elke vier seconden op de client voor verse state. Ja, polling. Websockets lagen op tafel; elf dispatchers die elke vier seconden refreshen is 165 requests per minuut, ruim onder wat één Postgres pool aan kan, en polling is om 03:00 's nachts te debuggen op een manier die een vastgelopen socket niet is.
// app/dispatch/actions.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { currentUser } from '@/lib/auth'
const Move = z.object({
shipmentId: z.string().uuid(),
toStatus: z.enum(['ready_for_bay', 'at_bay', 'loading', 'loaded']),
expectedVersion: z.number().int(),
})
export async function moveShipment(input: unknown) {
const { shipmentId, toStatus, expectedVersion } = Move.parse(input)
const user = await currentUser()
const result = await db.tx(async (tx) => {
const prev = await tx.one(
'select status from shipment where id = $1 for update',
[shipmentId],
)
const row = await tx.one(
`update shipment
set status = $1,
updated_at = now(),
updated_by = $2,
version = version + 1
where id = $3
and version = $4
returning id, status, version`,
[toStatus, user.id, shipmentId, expectedVersion],
)
await tx.none(
`insert into shipment_event
(shipment_id, from_status, to_status, actor_id)
values ($1, $2, $3, $4)`,
[shipmentId, prev.status, toStatus, user.id],
)
return row
})
revalidatePath('/dispatch')
return result
}
De version kolom is de optimistic lock. Twee dispatchers die in dezelfde seconde op "ready" klikken bij dezelfde zending: één wint, de ander krijgt een 409 en een ververste rij. Het werkboek loste dit op door stilletjes de laatst-opgeslagen versie eroverheen te schrijven. Het nieuwe board maakt de race zichtbaar en dwingt een menselijke beslissing af. En dat wilden de dispatchers eigenlijk al, zodra ze het in actie zagen.
De queue: Postgres SKIP LOCKED, geen Redis, geen RabbitMQ
Achter het board draaien vier background jobs: EDI-updates ophalen bij twee carriers, status-webhooks pushen naar het klantportaal, een PDF-manifest opnieuw genereren als een zending naar loaded gaat, en de klant mailen als hij vertrekt. Klassiek queue-werk.
We hebben geen Redis of RabbitMQ toegevoegd. De job queue is een Postgres tabel met één bekend query pattern: FOR UPDATE SKIP LOCKED.
create table job (
id bigserial primary key,
kind text not null,
payload jsonb not null,
run_after timestamptz not null default now(),
attempts int not null default 0,
locked_at timestamptz,
locked_by text,
finished_at timestamptz,
last_error text
);
create index job_pending_idx
on job (run_after)
where finished_at is null and locked_at is null;
-- A worker pulls one job atomically:
update job
set locked_at = now(),
locked_by = $1,
attempts = attempts + 1
where id = (
select id from job
where finished_at is null
and locked_at is null
and run_after <= now()
order by run_after
for update skip locked
limit 1
)
returning id, kind, payload;
Drie Node workers, elk pollend elke 500ms, botsen nooit. SKIP LOCKED is de feature die dit triviaal maakt: de tweede worker slaat simpelweg elke rij over waar de eerste een row-level lock op heeft, en pakt de volgende. De Postgres-docs beschrijven de semantiek bondig; de praktische versie is "zo bouw je een queue zonder queue".
De hele queue staat in dezelfde database als de dispatch state, wat betekent dat een job en zijn bronrij in één transactie geüpdatet kunnen worden. Geen consistency-probleem over twee systemen heen, geen Redis die stale wordt, geen message-replay logica. Als we het uiteindelijk ontgroeien, weten we het, omdat job_pending_idx in de slow-query logs gaat opduiken. Tot die tijd: less is more.
Nightly reconciliatie: vertrouwen, maar controleren
De eerste drie weken na de cutover hielden we het werkboek in leven. Dispatchers gebruikten het nieuwe board; een export-script schreef de board-state in een parallelle sheet om 23:55; een reconciliatie-job om 00:30 vergeleek de geëxporteerde sheet met de gezaghebbende state van het board en mailde elke drift naar de ops lead.
Dit klinkt paranoïde tot je bedenkt dat er acht jaar aan folklore in het werkboek zat ingebakken. De reconciliatie vond alleen al in de eerste week drie soorten drift:
- Een handvol zendingen waarbij het oude carrier-pull script
loadedterugzette naarat_baydoor een stale cache. Binnen een dag opgelost. - Eén klant die elke ochtend een CSV mailde om "bookings vooraf te laden" via een achterdeur die niemand had gedocumenteerd. We hebben er een importer voor gebouwd.
- Zesentwintig zendingen per dag in een status waar het board geen idee van had: "weighed". De dispatchers van de nachtdienst hielden het bij in een notepad-kolom. We hebben het toegevoegd als sub-state en de drift verdween.
In week drie was de diff vijf nachten op rij leeg. We zetten de export uit en de sheet ging op read-only. In week zes was hij weg.
Sla deze fase niet over. In de eerste week kom je erachter dat de spreadsheet twee verborgen workflows had, drie geheime integraties, en één klant die sinds 2019 rechtstreeks naar de warehouse manager mailt. Niets daarvan staat in de koppen. Alles staat in het drift-rapport.
Cutover via dual-write, geen big-bang
We hebben de dispatchers nooit gevraagd om op een datum over te stappen. Drie weken lang draaiden het board en de sheet parallel. Een klein bridge-script luisterde mee op het event log van het board en paste elke transitie toe op de sheet via een service-account login, zodat de sheet live bleef voor iedereen die nog niet wilde overstappen.
Op dag vijf zaten acht van de elf dispatchers op het board, omdat het sneller was. Op dag twaalf gingen de overige drie ook over, omdat ze het zat waren om in een sheet te typen die "vanzelf bewoog". Op dag eenentwintig zetten we de sheet op read-only en wachtten af. Geen klachten. We zetten de bridge uit.
De les, die we bij elke migratie van dit type opnieuw leren: de manier om een legacy systeem uit te faseren is door het nieuwe saai-beter te maken in het dagelijkse werkwoord, en het oude vervolgens vanzelf te laten wegkwijnen. Mensen het nieuwe gereedschap in praten is een verloren strijd tegen spiergeheugen.
Wat we opnieuw zouden doen, en wat niet
Opnieuw doen: Postgres voor zowel state als queue, server actions voor mutaties, een event tabel vanaf dag één, en een reconciliatie-fase die overlapt met het oude systeem. Niet doen: een custom drag-and-drop bouwen tot minstens drie dispatchers erom vragen. We hebben er één gebouwd in week zes en in week negen weer weggehaald. Niemand merkte het.
Toen we dit voor de expediteur opleverden, liepen we laat tegen een timezone drift aan tussen de EDI-feeds van de carriers (UTC) en de lokale departed timestamps van de dispatcher (Europe/Amsterdam, mét zomertijd). We hebben het opgelost door elke timestamp op te slaan als timestamptz, te renderen in de locale van de gebruiker, en één carrier-adapter per bron te schrijven in plaats van één gedeelde parser. Dit is het soort process automation werk dat we het vaakst doen: geen slim model, gewoon een zorgvuldig schema, een kleine queue, en een reconciliatie-job die de oude wereld verantwoordelijk houdt totdat de nieuwe zijn plek verdient.
Wil je morgen beginnen? Ga zes uur zitten kijken hoe één persoon de sheet gebruikt. Schrijf de werkwoorden op, niet de kolommen. De enum rolt vanzelf uit je notities.
Kern
Audit de werkwoorden (wat mensen doen met de sheet) voordat je naar de zelfstandige naamwoorden kijkt (hoe de kolommen heten). De werkwoorden zijn je schema; de zelfstandige naamwoorden zijn documentatie.
FAQ
Waarom Postgres voor de queue, en geen Redis of RabbitMQ?
Omdat de job en zijn bronrij in één transactie geüpdatet kunnen worden. Geen consistency tussen twee systemen om je zorgen over te maken. SKIP LOCKED geeft je per-rij werkverdeling over meerdere workers, gratis.
Hoe lang duurt zo'n migratie realistisch?
Elf weken voor deze expediteur, van de eerste audit tot het uitfaseren van de oude sheet. Het grootste deel was het dual-write reconciliatie-venster. De build zelf was ongeveer drie weken.
Wat als het team weigert van Excel af te stappen?
Vraag ze niet om op een datum over te stappen. Dual-write twee of drie weken lang vanuit het nieuwe systeem naar de sheet. De snelle gebruikers gaan eerst. De rest volgt als de sheet vanzelf gaat bewegen.
Hebben we server actions nodig, of werkt een API route ook?
Beide is prima. Server actions halen de JSON-marshalling boilerplate weg en sluiten natuurlijk aan op form submissions en revalidatePath. Een API route is de juiste keuze als je ook een non-browser client nodig hebt.
Waarom polling in plaats van websockets?
Elf gebruikers die elke vier seconden pollen is 165 requests per minuut. Triviaal voor één Postgres pool. Polling is ook makkelijker te debuggen om 03:00 's nachts dan een vastgelopen socket. Voeg sockets toe als de load er pas echt om vraagt.