Process automation
Douaneaangiften reconciliëren: vier-ogen-queue voor AGS
Donderdagavond 19:00 in Roermond. Twee aangiften zitten vast omdat iemand een dual-use classificatie miste, en de AGS-endpoint nam ze al aan.

Het is 19:00 op een donderdag in Roermond. Het team gaat naar huis. Twee douaneaangiften zitten vast in de gateway omdat een declarant een dual-use classificatie miste op een warmtebeeldcamera, en de AGS-submit-endpoint heeft de aangifte al aangenomen. De compliance-lead moet de aangifte nu terugroepen voordat de zending om 04:00 Rotterdam verlaat. Die recall kost een paar uur en een kleine berg papierwerk. De fix die het had voorkomen, is twaalf regels Python en één extra rij in de database.
Dit is de playbook voor die fix, opgeschreven nadat we hem live brachten bij een logistics-tech leverancier van 31 mensen. Hun declaranten dienen zo'n 5.400 douaneaangiften per week in via een dertien jaar oude Descartes e-Customs gateway, met een zelfgebouwde PostgreSQL-pakbonledger ernaast. De agent die we bouwden, reconcilieert beide systemen bij elke wijziging, parkeert alles wat naar dual-use ruikt, en weigert de AGS-submit-endpoint aan te roepen totdat twee paar menselijke ogen er groen licht voor hebben gegeven. De vorm van de playbook is generiek. De details niet, en daar zit het werk.
De vorm van het werk
De meeste douane-backoffices lijken van een afstand op elkaar. Een gateway die met het nationale douanesysteem praat (in Nederland is dat AGS, met DMS dat voor een groeiend deel van het verkeer online komt). Een packing-list of pakbonledger die het magazijn beheert. Een paar declaranten die beide met de hand synchroon houden. Verschillen ontstaan de hele dag door. Een pakbon wordt opnieuw uitgesneden na een deelzending. Een declarant past een HS-code aan. De gateway stuurt een gecorrigeerde MRN terug. Niks bijzonders.
Wat je nekt is de kleine fractie (minder dan één procent, bij deze klant) aangiften die goederen raken van de EU dual-use lijst. Onder Verordening (EU) 2021/821 heb je daarvoor een exportvergunning nodig, een check op het eindgebruik, en een schriftelijke registratie van wie de aangifte heeft goedgekeurd. Mis er één en je krijgt geen boete met een tik op de vingers. Je krijgt een bezoek van de Centrale Dienst voor In- en Uitvoer, en je AEO-status begint te wankelen.
De agent die we bouwden heeft dus twee taken. Snel reconciliëren. Weigeren te verzenden totdat een mens de dual-use beoordeling heeft bevestigd.
De twee systemen
De Descartes-kant is een SOAP-gateway uit 2013. Hij accepteert EDI-stijl XML, geeft MRN-nummers terug, en stuurt status-events op een queue. Hij is niet prettig om mee te praten. Hij is ook stabiel, het schema is in acht jaar niet veranderd, en de declaranten hebben gewoontes opgebouwd rond zijn exacte failure modes. We hebben hem niet vervangen. We hebben hem ingepakt.
De pakbon-kant is een PostgreSQL-database die het magazijnteam in een decennium heeft laten groeien. Drieëntachtig tabellen, geen foreign keys op het hot path, en de gewoonte om zending_id-rijen in place te herschrijven als een zending wordt gesplitst. Het magazijn is eigenaar van die database. Wij mogen er niet in refactoren. Wij mogen eruit lezen en naar een dunne events-tabel ernaast schrijven.
Daartussen zit een Python-service. Drie componenten: een reconciler, een queue, en een submitter. De reconciler draait op een vijf-minuten-tick. De queue is een Postgres-tabel. De submitter is een worker pool die met Descartes praat, één aangifte tegelijk.
Het reconciliatiecontract
Reconciliatie is een vijf-minuten-loop. Voor elke zending die in het laatste venster is veranderd (we gebruiken een last_touched_at-trigger die het magazijn al had), haalt de agent de bijhorende aangifte op uit Descartes, vergelijkt de canonieke velden, en markeert het paar als clean of legt een discrepantie vast.
Het contract is kort, en de moeite waard om op papier te zetten:
CANONICAL_FIELDS = (
"exporter_eori",
"consignee_eori",
"hs_code",
"country_of_destination",
"net_mass_kg",
"invoice_value_eur",
"incoterm",
"licence_ref",
)
def reconcile(zending_id: str) -> Reconciliation:
pakbon = load_pakbon(zending_id)
aangifte = descartes.fetch(pakbon.mrn) if pakbon.mrn else None
if aangifte is None:
return Reconciliation.new(zending_id, status="pending_submit")
diffs = [
Diff(field, getattr(pakbon, field), getattr(aangifte, field))
for field in CANONICAL_FIELDS
if getattr(pakbon, field) != getattr(aangifte, field)
]
if not diffs:
return Reconciliation.clean(zending_id, mrn=aangifte.mrn)
return Reconciliation.discrepant(zending_id, diffs=diffs)
Niks slims. De slimheid zit in de acht velden. Kies er minder en je mist echte discrepanties. Kies er meer en de declaranten besteden hun middagen aan het wegwerken van ruis door cosmetische verschillen in adressering of verpakking.
Reconciliatie is een contract, geen diff. Schrijf de canonieke velden op papier samen met de declarant die de meeste aangiften indient, vóór je code schrijft. Acht is meestal het juiste aantal.
Dual-use als apart pad
Elke aangifte die uit de reconciler komt met status pending_submit gaat door een dual-use check vóór hij ook maar in de buurt van Descartes komt. De check is niet subtiel. We houden een lokale kopie van Bijlage I van de dual-use verordening bij, geïndexeerd op HS-hoofdstuk, land van bestemming, en een paar keyword-heuristieken op de goederenbeschrijving.
Als de HS-code een gecontroleerde categorie raakt, of de beschrijving woorden bevat van de watchlist (thermal, encryption, frequency, navigation, centrifuge, en nog veertig andere), verzendt de agent niet. Hij schrijft de aangifte in de vier-ogen-queue en stuurt een Slack-bericht naar het compliance-kanaal. De submit-endpoint wordt nooit aangeroepen.
Dit is het stuk waarop we geen compromissen sluiten. De agent is bevooroordeeld richting parkeren, niet richting verzenden. De classifier geeft een confidence score tussen nul en één terug, maar we lezen die nooit als autosubmit-signaal. Het compliance-team verbrandt liever tien minuten aan het bevestigen van een overduidelijk prima warmtebeeld-zending dan dat ze de check overslaan op één die ertoe deed. De score sorteert dus de queue. Hij stuurt de beslissing niet. De watchlist zelf is opgebouwd in een week aan avonden, naast de declarant die de meeste aangiften van het team heeft ingediend. Elk woord erop heeft zijn plek verdiend door aan een echte aangifte te hangen die hij zich kon herinneren.
De vier-ogen-queue
De queue is een PostgreSQL-tabel naast de pakbonledger. Het is geen message broker. Het is geen Kafka. Het is geen workflow engine. Het is een tabel. De reden is operationeel. Als het magazijnteam wil weten wat vast zit, openen ze psql en draaien een query. Dat kunnen ze al. Een nieuw systeem toevoegen betekent een nieuw ding leren om 22:00 op een zondag, en op dat moment moet er niks nieuws zijn.
CREATE TABLE compliance_queue (
id bigserial PRIMARY KEY,
zending_id text NOT NULL,
reason text NOT NULL,
payload jsonb NOT NULL,
parked_at timestamptz NOT NULL DEFAULT now(),
first_reviewer text,
first_decision text CHECK (first_decision IN ('approve','reject')),
first_at timestamptz,
second_reviewer text,
second_decision text CHECK (second_decision IN ('approve','reject')),
second_at timestamptz,
released_at timestamptz,
UNIQUE (zending_id, parked_at)
);
CREATE INDEX compliance_queue_open
ON compliance_queue (parked_at)
WHERE released_at IS NULL;
Twee reviewers, twee timestamps, twee vastgelegde beslissingen. De reviewer kan niet twee keer dezelfde persoon zijn (we checken tegen de SSO van de declarant). De agent geeft alleen vrij als beide rijen zijn gevuld en beide beslissingen approve zijn. Elke reject van een van beide reviewers sluit de rij met released_at = now() en de pakbon krijgt een vlag in het magazijnsysteem zodat hij niet wordt verzonden.
Voor de worker die aangiften van de queue plukt zodra ze zijn vrijgegeven, gebruiken we FOR UPDATE SKIP LOCKED. Dat houdt het ontwerp saai. Geen aparte broker, geen apart consensus-protocol, geen race tussen twee workers die dezelfde rij pakken.
SELECT id, zending_id, payload
FROM compliance_queue
WHERE released_at IS NULL
AND first_decision = 'approve'
AND second_decision = 'approve'
ORDER BY parked_at
FOR UPDATE SKIP LOCKED
LIMIT 1;
Idempotentie vóór de AGS-submit-endpoint
De Descartes submit-call is de enige plek in dit systeem waar een fout onomkeerbaar is. Zodra je een MRN hebt, heb je de Nederlandse douane verteld dat een zending gaat. Dat corrigeren is papierwerk. Het verdubbelen is papierwerk in brand.
Dus wikkelen we de submit-endpoint in een strikte idempotentielaag. Elke aangifte krijgt een deterministische submission_key, afgeleid uit de hash van de pakbon plus een monotone versie. De submitter legt de key vast in een submissions-tabel binnen een transactie, roept Descartes aan, en commit de rij alleen als de gateway een MRN teruggeeft. Als de gateway timeout krijgt, blijft de rij ongecommit, is de key nog vrij, en zal de retry-loop het opnieuw proberen met dezelfde key.
def submit(declaration: Declaration) -> str:
key = submission_key(declaration)
with db.transaction() as tx:
existing = tx.fetchone(
"SELECT mrn FROM submissions WHERE submission_key = %s",
(key,),
)
if existing and existing.mrn:
return existing.mrn
tx.execute(
"INSERT INTO submissions (submission_key, declaration_id, started_at)"
" VALUES (%s, %s, now())"
" ON CONFLICT (submission_key) DO NOTHING",
(key, declaration.id),
)
mrn = descartes.submit(declaration) # may raise
tx.execute(
"UPDATE submissions SET mrn = %s, committed_at = now()"
" WHERE submission_key = %s",
(mrn, key),
)
return mrn
Het patroon doet er meer toe dan de code. Een submission key die het magazijn met de hand kan reproduceren. Een rij die bestaat vóór de netwerk-call. Een commit die pas plaatsvindt nadat de gateway heeft bevestigd. Wat je ook doet, leid de key niet af uit een wall-clock timestamp. Een klokverschil tussen twee workers geeft je twee keys voor dezelfde zending, en dat ontdek je op het slechtst denkbare moment.
De shadow-mode maand
Voordat we de agent iets lieten verzenden, draaide hij dertig dagen in shadow mode. Elke aangifte die de declaranten met de hand indienden, werd ook door de agent verwerkt op een parallel pad. Het reconciliatieresultaat ging in een parallelle tabel, de dual-use beslissing in een parallelle queue, en een nightly job diffde beide stromen. De declaranten zagen er niks van. Het doel was om elk meningsverschil zichtbaar te maken voordat het ertoe deed.
In week één was de agent het op ongeveer vier procent van de aangiften oneens met de declaranten. Eind week drie zaten we op één procent, en bijna alle resterende meningsverschillen waren zendingen die de agent parkeerde terwijl de declaranten ze mondeling aan het bureau hadden goedgekeurd. Dat is precies het failure mode dat je wilt. Een false positive in de queue kost tien minuten van een reviewer. Een false negative op een dual-use zending kost je AEO-status.
De shadow-maand was het enige dat de operations director het vertrouwen gaf om de productie-schakelaar om te zetten. We hebben hem ook bij de volgende twee klanten niet overgeslagen, ook al waren we toen zeker van de code. De code was nooit de vraag. Het vertrouwen wel.
Observability die de maandagochtend overleeft
De agent logt drie dingen, en alleen drie dingen, op een manier die het operations-team ook echt leest. Een dagelijkse digest om 07:30 naar het compliance-kanaal met het aantal aangiften dat is verzonden, geparkeerd, en afgewezen. Een real-time alert voor elke submission die meer dan dertig seconden nodig heeft om bevestigd te worden. Een wekelijkse export van de vier-ogen-queue met de mediane tijd tot tweede review.
Die laatste is het belangrijkst. Als tweede reviews gemiddeld langer dan vier uur duren, is de queue geen vangnet meer. Dan is hij een bottleneck, en de declaranten gaan eromheen werken (meestal door een collega te vragen om de tweede goedkeuring te stempelen). We houden dat getal scherper in de gaten dan welke latency-metric op de gateway dan ook. Als hij wegdrijft, voegen we een reviewer toe of scherpen we de watchlist aan. We laten hem nooit liggen.
Wat er veranderde voor de declaranten
Voordat de agent live ging, had het Roermondse team twee declaranten die elk ongeveer een dag per week bezig waren met discrepanties tussen de pakbonledger en de gateway. Ze besteden er nu zo'n uur per week aan, bijna allemaal in de vier-ogen-queue, en dat is de tijd die ze ook hóren te besteden. De dual-use checks die vroeger een mondeling "volgens mij zit het wel goed" aan het bureau waren, zijn nu een rij met twee namen en twee timestamps. Dat is het stuk waar de compliance-auditors om geven.
De eerste maand na go-live ving de queue twee echte dual-use fouten die anders waren doorgegaan. De ene was een frequentiestoorder-component bestemd voor een Turkse expediteur waar het eindgebruik op het papierwerk meerduidig was. De andere was een warmtebeeldunit, gerouteerd via een Singaporese ontvanger met vervolgpapieren die wezen op re-export naar een bestemming op een sancties-shortlist. Geen van beide was duidelijk uit de pakbon. Allebei zijn ze gevangen omdat de classifier de beschrijving markeerde en een tweede reviewer een vraag stelde die de eerste reviewer niet had bedacht.
Niks hiervan is exotisch. De agent is een Python-service, een Postgres-tabel, en respect voor de systemen die er al stonden. Toen we de process-automation agent bouwden die deze reconciliatie draait, was de verleiding die we steeds onderdrukten de neiging om de dertien jaar oude Descartes-wrapper te vervangen door iets moderns. Dat hebben we niet gedaan. We hebben hem strakker ingepakt, en de declaranten kregen geen pager-alerts meer om 22:00 op zondag.
Run je een douane-backoffice en wil je weten waar je moet beginnen: schrijf morgenochtend je acht canonieke velden op, op papier, samen met de declarant die de meeste aangiften indient. Dat gesprek is de hele playbook in het klein.
Kern
Reconciliatie is een contract, geen diff. Schrijf je acht canonieke velden op papier samen met de declarant die de meeste aangiften indient, vóór je code schrijft.
FAQ
Waarom de oude Descartes-gateway inpakken in plaats van vervangen?
Hij is stabiel, de declaranten hebben gewoontes opgebouwd rond zijn failure modes, en de kosten van vervangen overschaduwen de kosten van inpakken. We hebben hem strakker ingepakt en hem zijn werk laten doen.
Waarom acht canonieke reconciliatievelden en niet vijftien?
Acht vangt elke echte discrepantie die we hebben gezien. Vijftien verzuipt de declaranten in cosmetische ruis. Het getal is een contract met de mensen die de queue leegmaken, geen technische keuze.
Wat komt er in de vier-ogen-queue?
Elke aangifte waarvan de HS-code of de goederenbeschrijving op de EU dual-use controlelijst staat, of waarvan de bestemming op een sancties-shortlist staat. Twee verschillende reviewers moeten goedkeuren voor submit.
Kan de agent een aangifte verzenden zonder menselijke goedkeuring?
Ja, voor de 99 procent die niet dual-use is en die clean reconcilieert. Het interessante werk is die ene procent zichtbaar en onmisbaar maken, niet mensen uit de loop halen.
Waarom Postgres voor de queue in plaats van een message broker?
Het magazijnteam kent psql al. Om 22:00 op een zondag is de juiste tool degene die je operators uit hun hoofd kunnen bevragen. SKIP LOCKED handelt de concurrency af die we nodig hebben.