Process automation
Van Google Sheet naar Postgres: warehouse in een halve dag
De magazijnsheet hangt weer om 11:18, met drie pickers die er vanaf de vloer in editen. Je hebt een halve dag voor de volgende vrachtwagen om 'm naar Postgres te verhuizen.

Het is woensdagmiddag. De operations lead van een onderdelendistributeur bij Eindhoven stuurt een screenshot: de Google Sheet die 4.200 SKU's bijhoudt, is weer vastgelopen omdat drie pickers en een inkoper er vanaf de vloer tegelijk in editen. De kolom "Voorraad" heeft twee versies, een van 11:14 en een van 11:18, en niemand weet welke klopt. De sheet heeft 38 tabbladen, een VLOOKUP die elf seconden nodig heeft om opnieuw te rekenen, en een script-trigger die bij elke save twee keer afgaat.
Je hebt een halve dag voordat de volgende inbound truck komt. Je kunt die tijd besteden aan het fixen van de sheet, of je verhuist de data naar Postgres met een kleine admin erbovenop en raakt de sheet nooit meer aan. Dit is geen migratieverhaal in de Airbyte-en-dbt zin. Het is de stille versie: één tabel, één CSV, één formulier, en een team dat op woensdagmiddag geen data meer kwijtraakt. Dit is die tweede optie, uur voor uur.
Uur één: lees de sheet eerlijk
Open de sheet niet met de bedoeling om te migreren. Open 'm met de bedoeling om 'm te begrijpen. Spreadsheets die echte operations draaien, doen altijd drie dingen tegelijk: ze slaan data op, ze rekenen rapporten uit, en ze houden workflow-regels vast in celkleuren en conditional formatting. Jouw Postgres-tabel vervangt alleen het eerste. De andere twee verhuizen naar code of naar de admin-UI.
Pak het ene tabblad dat de bron van waarheid is. Er is er altijd één. Al het andere is een view, een buffer, of een rapport. Bij onze distributeur was dat het tabblad Voorraad, 4.217 rijen breed, 22 kolommen diep. Van die kolommen waren er acht echte data en de rest was afgeleid (VLOOKUPs, ARRAYFORMULAs, statusvlaggen via celkleur). Schrijf dat op papier. Je raadpleegt het de komende anderhalf uur drie keer.
Lijst dan de schrijvers op. Wie editet deze sheet daadwerkelijk, en hoe vaak? In ons geval: twee magazijnpickers (elk kwartier tijdens een dienst), één inkoper (twee keer per dag), en de operations lead (zodra er iets stuk gaat). Vier mensen. Dat is je concurrency-budget. Je bouwt geen Shopify. Je bouwt iets waar drie tot vijf mensen tegelijk op hameren, en dat scheelt je een hoop architectuur.
Besteed de laatste tien minuten aan het lezen van de conditional formatting. Pickers en inkopers stoppen betekenis in celkleuren: rood betekent verwijderd maar niet echt verwijderd, geel betekent wachten op de leverancier, lichtgroen betekent vandaag geteld. Elke kleur is óf een kolom die je aan je schema moet toevoegen, óf een workflow die je daarbuiten moet eren. Het lastige aan het vervangen van een spreadsheet is niet het schema. Het is uitvinden welke kolommen data waren en welke een UI-hack die het team bouwde omdat het geen ander gereedschap had.
Uur twee: een schema dat maandag overleeft
Je hebt nu een lijst van acht echte kolommen. Weersta de drang om de wereld te modelleren. Je ontwerpt niet voor het komende decennium. Je ontwerpt voor de komende maand, en je wilt dat migraties goedkoop zijn als blijkt dat je iets verkeerd hebt ingeschat.
Bij de distributeur zag de tabel er zo uit:
create table sku (
id bigserial primary key,
code text not null unique,
description text not null,
location text not null,
on_hand integer not null default 0,
reorder_at integer not null default 0,
supplier text,
notes text,
updated_at timestamptz not null default now(),
updated_by text not null default 'system'
);
create index sku_location_idx on sku (location);
create index sku_low_stock_idx on sku (on_hand) where on_hand < reorder_at;
Drie dingen om op te merken. De kolom code is de natuurlijke sleutel uit de sheet, maar de tabel krijgt alsnog een synthetische id. Foreign keys naar een SKU mogen nooit afhangen van het operationsteam dat een onderdeel hernoemt. De kolommen updated_at en updated_by zitten er vanaf dag één in, omdat de eerste vraag na go-live altijd is "wie heeft dit wanneer aangepast". En de partial index op low stock is het rapport dat het team nu draait door de sheet te sorteren en te turen. Maak er een query van.
Twee kleine keuzes die het verdedigen waard zijn. bigserial in plaats van uuid, omdat een magazijn met tienduizend SKU's nooit door bigint heen gaat, en een sorteerbare primary key maakt de admin-lijst goedkoper om te pagineren. timestamptz in plaats van timestamp, omdat iemand de admin uiteindelijk vanuit een andere tijdzone gaat draaien, en een naïeve timestamp verschuift dan stilletjes met een uur. Beide keuzes kosten vandaag niets en besparen je over zes maanden een migratie.
Wil je een volledige audit trail, voeg dan een tweede tabel toe:
create table sku_event (
id bigserial primary key,
sku_id bigint not null references sku(id) on delete cascade,
field text not null,
old_value text,
new_value text,
changed_at timestamptz not null default now(),
changed_by text not null
);
Een trigger vult die bij elke update. Je gaat jezelf bedanken de eerste keer dat iemand vraagt waarom een telling 's nachts is gedaald. De Postgres-docs over trigger functions beschrijven het patroon in ongeveer veertig regels.
Uur drie: de load
Exporteer de sheet als CSV (Bestand, Downloaden, Door komma's gescheiden waarden). Gebruik bij de eerste load geen live API-koppeling. Je wilt één bevroren snapshot dat je opnieuw kunt draaien als de import onvermijdelijk een edge case mist.
Maak de CSV schoon in code, niet met de hand. Pickers typen "12 stuks" in een aantalkolom. Inkopers laten spaties achter. Eén op de 200 rijen heeft een komma in de omschrijving en is met verkeerde quoting opgeslagen. Een Node-script van veertig regels handelt dat allemaal af en geeft je een diff die je aan de operations lead kunt laten zien voordat de data Postgres raakt.
import { readFileSync } from 'node:fs'
import { parse } from 'csv-parse/sync'
import postgres from 'postgres'
const sql = postgres(process.env.DATABASE_URL)
const rows = parse(readFileSync('voorraad.csv'), {
columns: true,
skip_empty_lines: true,
trim: true,
})
const cleaned = rows
.filter(r => r.Artikelcode)
.map(r => ({
code: r.Artikelcode.trim().toUpperCase(),
description: r.Omschrijving.replace(/\s+/g, ' ').trim(),
location: (r.Locatie || 'UNASSIGNED').trim(),
on_hand: parseInt(String(r.Voorraad).replace(/[^0-9-]/g, ''), 10) || 0,
reorder_at: parseInt(r.Minimum, 10) || 0,
supplier: r.Leverancier?.trim() || null,
notes: r.Notities?.trim() || null,
}))
for (const row of cleaned) {
await sql`
insert into sku ${sql(row)}
on conflict (code) do update set
description = excluded.description,
location = excluded.location,
on_hand = excluded.on_hand,
reorder_at = excluded.reorder_at,
supplier = excluded.supplier,
notes = excluded.notes,
updated_at = now(),
updated_by = 'csv-import'
`
}
console.log(`Loaded ${cleaned.length} rows.`)
await sql.end()
Voordat je iets in productie insert, log je de eerste tien schoongemaakte rijen en zet je die voor de operations lead. Hij ziet binnen vijftien seconden de kolom die je verkeerd hebt. Als Voorraad binnenkomt als een mengsel van cijfers en Nederlandse woorden, wil je dat op het scherm zien voordat het in een kolom van type integer landt.
Draai het eerst tegen een staging-database. Diff de rijtelling met de sheet. Mis je rijen, dan is het bijna altijd één van drie dingen: een lege artikelcode, een rij die verborgen is door een filter dat je vergeten bent te wissen, of een rij die het team verwijderd noemt door 'm rood te kleuren maar nooit echt heeft weggehaald. Fix het script, niet de data, en draai opnieuw.
Vind je rijen die alleen bestaan vanwege "soft deletes" via conditional formatting, laat de operations lead dan voor de cutover bevestigen wat ermee moet gebeuren. Ghost-rijen mee Postgres in slepen is de snelste manier om op dag één het vertrouwen in het nieuwe systeem te verliezen.
Uur vier: de admin
Je bouwt geen CMS. Je hebt drie schermen nodig: een doorzoekbare lijst, een edit-formulier, en een low-stock view. Next.js met de app router geeft je alle drie in minder dan 200 regels, en server actions halen de noodzaak voor client-side state management weg.
De lijstpagina is een server component die rechtstreeks Postgres queryt:
// app/sku/page.tsx
import { sql } from '@/lib/db'
import Link from 'next/link'
export default async function SkuList({ searchParams }) {
const q = (searchParams.q ?? '').trim()
const rows = await sql`
select id, code, description, location, on_hand, reorder_at
from sku
where ${q
? sql`code ilike ${'%' + q + '%'} or description ilike ${'%' + q + '%'}`
: sql`true`}
order by code
limit 200
`
return (
<main>
<form><input name="q" defaultValue={q} placeholder="Search SKU or description" /></form>
<table>
<thead>
<tr><th>Code</th><th>Description</th><th>Location</th><th>On hand</th></tr>
</thead>
<tbody>
{rows.map(r => (
<tr key={r.id} className={r.on_hand < r.reorder_at ? 'low' : ''}>
<td><Link href={`/sku/${r.id}`}>{r.code}</Link></td>
<td>{r.description}</td>
<td>{r.location}</td>
<td>{r.on_hand}</td>
</tr>
))}
</tbody>
</table>
</main>
)
}
De edit-pagina is een formulier dat post naar een server action. De action doet één ding: de rij updaten en een event wegschrijven. Geen optimistic UI, geen toast library, geen state machine. Een redirect na de update is snel genoeg dat het team het niet merkt.
// app/sku/[id]/actions.ts
'use server'
import { sql } from '@/lib/db'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
export async function updateSku(id, formData) {
const user = await auth()
const on_hand = parseInt(formData.get('on_hand'), 10)
const location = formData.get('location').trim()
await sql`
update sku
set on_hand = ${on_hand},
location = ${location},
updated_at = now(),
updated_by = ${user.email}
where id = ${id}
`
redirect('/sku')
}
Auth kan het simpelste zijn dat werkt. Voor vier gebruikers op een intern netwerk is een gedeelde link met een passphrase achter Next.js middleware prima voor week één. Voeg later Google SSO toe als de magazijnchef erom vraagt, niet eerder.
Voeg geen paginering, multi-kolom sortering, opgeslagen filters of row-level highlighting toe tot iemand er echt om vraagt. De week-één admin is een dunne schil. Elke feature die je toevoegt voordat het team erom vraagt, is een feature die je later moet verdedigen als iemand 'm eruit wil hebben, en magazijnteams zijn meedogenloos over ongebruikte knoppen die in de weg zitten van het count-veld.
Deploy op Vercel of een kleine VPS. Allebei werkt. Wijs Postgres aan op Supabase, Neon, of een managed RDS-instance. De keuze van de database doet er minder toe dan het goed krijgen van de cutover.
De cutover
Kies een rustig moment. Voor een magazijn is dat einde dienst op een doordeweekse dag, niet maandagochtend. Vertel het team schriftelijk wat er verandert, wat ze moeten doen als het nieuwe tool stuk gaat, en wie ze kunnen bellen. Een geprinte one-pager die op het pickstation hangt, slaat elke chat-aankondiging.
Neem geprinte cheat sheets mee naar de vloer voordat je omzet. Eén vel, één kant, met de nieuwe URL, de inlog-passphrase, en de drie acties die het team dagelijks doet: voorraadaantal aanpassen, locatie wijzigen, SKU markeren voor herbestelling. De pickers raadplegen het twee keer op dag één en daarna nooit meer, en dat is precies de juiste hoeveelheid trainingsmateriaal.
Doe op het moment van de cutover precies dit:
- Sluit de sheet (Bestand, Delen, alleen-lezen voor iedereen).
- Exporteer een verse CSV.
- Draai het load-script tegen de productiedatabase.
- Deel de admin-URL uit.
- Blijf de komende anderhalf uur bereikbaar.
De eerste edits onthullen drie dingen die je hebt gemist. Een locatiecode met een schuine streep erin. Een SKU die de pickers bijhouden maar waar de sheet nooit een kolom voor had. Een veld dat de inkoper nodig heeft waarvan jij dacht dat het afgeleid was. Fix ze live. Elke fix is een schemawijziging van vijf minuten en een deploy.
Wat de komende week draait
Laat de sheet één week zichtbaar maar alleen-lezen staan. Verwijder 'm niet. Twee redenen: het team blijft oude opmerkingen in cellen raadplegen, en je wilt een bevroren referentie als een getal in Postgres vreemd lijkt. Na zeven dagen archiveer je de sheet naar een map met de naam archive-pre-postgres-2026 en ga je verder.
Voeg in die week drie dingen toe. Een nachtelijke back-up van de Postgres-database (je provider heeft dat vrijwel zeker ingebouwd, zet 'm aan). Een wekelijkse CSV-export uit de nieuwe admin, gemaild naar de operations lead, zodat hij de spreadsheet heeft die hij gewend is, zonder dat iemand erin editet. En een simpele low-stock email die elke ochtend om 07:00 afgaat, met elke SKU onder zijn herbestelniveau. Die laatste is meestal het moment waarop het team de sheet niet meer mist.
Bekijk de audit-tabel op dag twee. Sorteer op changed_at aflopend en scan de laatste honderd rijen. Je zoekt onverwachte patronen: één picker die alle edits doet omdat de anderen stilletjes het nieuwe tool hebben opgegeven, twee gebruikers die binnen twintig seconden dezelfde SKU bewerken, of een veld dat je vergeten bent toe te voegen waar iemand de waarde nu in de notes-kolom in schrijft. Elk patroon is feedback die de spreadsheet je nooit gaf.
Het patroon, niet het project
Deze playbook werkt omdat de scope eerlijk is. Je bouwt geen ERP. Je vervangt één spreadsheet door één tabel en een dunne UI, en je doet het in de tijd die het magazijn nodig heeft om twee inkomende vrachtwagens te verwerken. Dezelfde vorm geldt voor de facturentracker die de finance-afdeling draait, de leadpipeline die marketing in Airtable bijhoudt, en de projectplanning die in een Notion-database met zeventien views leeft.
Toen we vorige maand zo'n soort procesautomatisering bouwden voor een groothandel in Limburg, was het ding dat we bij de cutover tegenkwamen een kolom genaamd Status die vier verschillende concepten bleek te bevatten, afhankelijk van de rij. We hebben 'm voor go-live opgesplitst in drie booleankolommen en niemand heeft ooit gevraagd om de oude versie terug. Pak de spreadsheet die deze week het meeste pijn doet en geef hem een halve dag.
Kern
Een spreadsheet vervangen door een Postgres-tabel plus een dunne Next.js admin is een halve dag werk, zodra je accepteert dat je geen ERP bouwt maar gewoon één sheet uit dienst neemt.
FAQ
Waarom Postgres en niet gewoon in Google Sheets blijven met Apps Script?
Sheets loopt vast bij gelijktijdige edits en heeft geen echte audit trail. Postgres geeft je constraints, indexes, en een history-tabel op rijniveau die overleeft als iemand voor het eerst vraagt waarom een telling 's nachts is veranderd.
Heb ik een ORM nodig voor de Next.js admin?
Nee. Voor drie schermen en vier gebruikers is een dunne SQL-client zoals postgres.js genoeg. Voeg later Drizzle of Prisma toe als je eruit groeit, niet eerder.
En offline edits vanaf de magazijnvloer?
Heeft de vloer onbetrouwbare wifi, hou de admin dan online-only en geef pickers een papieren teltvel dat aan het eind van de dienst wordt ingetypt. Echte offline sync is een apart, veel groter project.
Hoe ga ik om met de inkoper die per se met een spreadsheet wil blijven werken?
Mail hem een dagelijkse CSV-export van diezelfde tabel. Hij krijgt zijn spreadsheet, jij houdt één bron van waarheid, en edits gebeuren alleen in de admin.
Kan dit ook zonder het load-script in Node te schrijven?
Ja. psql met COPY werkt voor schone CSV's, en Python met pandas is prima als je dat liever hebt. De scripttaal doet er minder toe dan het twee keer in staging draaien voordat je productie aanraakt.