Mobiele Transporter-app
TST Terschelling
Kiewiet Rijwielverhuur BV
Op het Nederlandse eiland Ameland komt vrijwel iedere toerist met de veerboot aan en heeft meteen een fiets nodig. Kiewiet Cyclisme is de meest gevestigde fietsverhuurder van het eiland. Achter de website staat een op maat gebouwd platform voor alle onderdelen van het bedrijf: online reserveringen, bagagevervoer vanaf de veerhaven, winkelverkoop, samenwerking met reisagenten, betalingen, facturen en een reeks desktopwerkplekken in de winkels. Dit is het verhaal van dat systeem.
De meeste boekingsplatforms lossen één vraagstuk op. Kiewiet moest er vier tegelijk oplossen en ze verbinden tot één samenhangende ervaring.
Alle vier de processen komen samen in dezelfde winkelwagen, betaalgateway, ordernummering en factuurverwerking.
Het platform is gebouwd in PHP 8.4 zonder extern framework, dus zonder Laravel of Symfony. Elke laag, van routing tot ORM, is specifiek voor deze toepassing ontworpen en geschreven.
Elke publieke URL komt binnen via één ingang: index.php. Apache stuurt met .htaccess alle verzoeken die geen bestand opvragen hiernaartoe. Een eigen router (NSSwitchBoard) verwerkt maximaal zes URI-padniveaus, vergelijkt het eerste segment met de routingtabel en laadt de juiste paginacontroller. Onbekende routes krijgen een 400 Bad Request en worden naar de homepage doorgestuurd, zonder algemene foutpagina’s die stacktraces prijsgeven.
Elke paginacontroller is een klasse die voortbouwt op een gedeelde basis (CyclismeWebPage) en als singleton in de PHP-sessie wordt bewaard. Daardoor blijft de volledige paginatoestand tussen verzoeken behouden: winkelwagen, gekozen datums, actieve productfilters en de inlogstatus van reisagenten, zonder dat de database voor interfacetoestand hoeft te worden uitgelezen. Een klant kan tien minuten rondkijken, fietsen uit meerdere categorieën toevoegen en daarna afrekenen. De server reconstrueert bij ieder verzoek de precieze sessietoestand uit het geheugen.
Databasemodellen volgen een patroon met twee bestanden. Een _base.php-bestand wordt automatisch uit het databaseschema gegenereerd en bevat uitsluitend getypeerde eigenschappen en standaardcode voor getters en setters. Dit wordt nooit met de hand gewijzigd. Een tweede bestand voegt de bedrijfslogica toe. Daardoor kunnen schemawijzigingen opnieuw worden gegenereerd zonder eigen code te overschrijven.
De ORM-basisklasse (NSPersistentObject) houdt wijzigingen op twee manieren bij: een expliciete wijzigingsvlag die de code instelt en een MD5-controlesom van alle geserialiseerde eigenschappen, berekend bij het laden. Als de vlag is gezet of de controlesom is veranderd, slaat een store()-aanroep het object op. Dit voorkomt onbedoelde dubbele schrijfacties en maakt gedeeltelijke updates veilig.
De opslaglogica kiest tijdens uitvoering tussen INSERT en UPDATE door te controleren of de UUID van het record al in de database staat. Er zijn geen afzonderlijke methoden voor aanmaken en bijwerken; de ORM beslist. Gekoppelde modelverzamelingen, zoals artikelregels binnen een reservering, horen bij bovenliggende objecten en worden met één gecoördineerde aanroep opgeslagen.
Een lichte querybouwer (DataStore) voegt positionele parameters toe aan de ORM. Query’s zien er bijvoorbeeld zo uit: query("Groep_uuid=:1 AND Verhuur>0", $groupUUID). De bouwer bepaalt typen automatisch: gehele getallen worden zonder escaping doorgegeven, tekenreeksen gaan door de escape-functie van de database, arrays worden uitgebreid tot SQL IN (...)-lijsten en eigen datum- en tijdtypen worden naar MySQL-formaat omgezet. Het resultaat is een EntitySelection: een iterator die volledige modelobjecten direct of pas bij gebruik laadt.
EntitySelection ondersteunt ook verzamelingsbewerkingen in het geheugen: doorsnede, vereniging en verschil van resultaten op basis van ID-lijsten. Zo kunnen meerdere queryresultaten worden gecombineerd zonder extra serververzoeken.
Beschikbaarheid voor verhuur is meer dan een voorraadteller. Een fiets die van dinsdag tot vrijdag is gereserveerd, is in elke overlappende periode niet beschikbaar. Een fiets die maandagochtend terugkomt, kan maandagmiddag wel beschikbaar zijn als er genoeg tijd is om deze gereed te maken. Producten::buildAvailabilityMatrix() berekent voor de hele gevraagde periode de beschikbaarheid per product en per dag, rekening houdend met alle bestaande reserveringen. Deze matrix voedt de actuele kalenderweergave op de boekingspagina.
Prijzen hangen af van de productgroep, het aantal huurdagen en de periode van het jaar. Een apart prijsmodel (Prijzen) bewaart regels per groep, duur en datumbereik. Bij het afrekenen bepaalt de software voor ieder winkelwagenartikel de toepasselijke prijs. Reisagentenaccounts hebben een eigen prijsgroep. Dezelfde fiets kan daardoor een ander tarief hebben bij een directe boeking dan bij een boeking via een partner.
Kortingscodes worden tijdens het boeken direct gecontroleerd op geldigheid, minimale orderwaarde en toepasselijke productcategorieën. De korting wordt in het winkelwagentotaal verwerkt en vastgelegd in de reservering en op de factuur.
Het bagagevervoer moet aansluiten op de dienstregeling van Wagenborg. Klanten kiezen een overtocht en het systeem controleert of het gewenste vervoerstijdstip daarbij past. Een aparte factoryklasse (FerryScheduleFactory) haalt de dienstregeling op via een externe API, voegt nieuwe gegevens toe of werkt bestaande bij en bewaart deze lokaal voor snelle raadpleging.
Het platform koppelt met twee betaalgateways: OGone (Ingenico) en EMS. Klanten kiezen hun betaalwijze tijdens het afrekenen. Na de terugmelding van de betaalgateway bevestigt Reservering::ValidateReservation() de betaalstatus, genereert een ordernummer, slaat de reservering op, maakt een pdf-bevestiging en verstuurt een bevestigingsmail. Dit gebeurt als één ondeelbare reeks handelingen na de betaling.
Een factory genereert ordernummers met tellers per ordertype: reserveringen, winkelverkopen, bagageorders en facturen. De tellers worden automatisch bij jaar- en maandwisselingen opnieuw gestart. Nummers krijgen voorloopnullen tot een vaste lengte, zodat alle documenten dezelfde opmaak hanteren.
Het platform maakt zelf drie soorten pdf-documenten:
Een PostOffice-klasse gebruikt PHPMailer om deze documenten als e-mailbijlagen te versturen, samen met HTML-bevestigingsberichten op basis van templates.
Een aparte REST-API-ingang (ApiEntrypoint.php) bedient de mobiele scanapp van transportmedewerkers op het eiland. Na authenticatie via een X-Auth-Token-header biedt deze endpoints om pakketgegevens op te halen, afleveringsscans in te dienen en pakketlocaties bij te werken. Elke scangebeurtenis past de bagagestatus direct aan. Klanten zien die status wanneer zij hun order bekijken.
Het webplatform werkt niet op zichzelf. Op ongeveer twintig macOS-werkplekken in de winkels van Kiewiet op Ameland draait daarnaast een desktopapplicatie in 4D v20R8: een volledig relationeel applicatieplatform met een eigen interface, gegevenslaag en bedrijfslogica.
De desktopapp beheert de volledige dagelijkse bedrijfsvoering: medewerkers melden klanten aan en geven fietsen mee aan de hand van verhuurbewijzen (VerhuurBonnen), verwerken reparaties zonder afspraak, beheren de onderdelenvoorraad van de werkplaats, bedienen de kassa en plannen bagageroutes.
Beide systemen delen een MySQL-database, maar gebruiken niet simpelweg dezelfde verbinding. Wijzigingen lopen via een speciale synchronisatielaag boven op een RecordSync-tabel die als wijzigingslogboek fungeert.
Als de PHP-webwinkel een record aanmaakt of wijzigt, bijvoorbeeld een reservering, betaalbevestiging of klant, schrijft deze een regel in RecordSync met een volgnummer en de applicatieaanduiding "Online". Een achtergrondproces in 4D vraagt deze wijzigingen op via bridge.php, haalt de gewijzigde records op en voegt ze met ORDA (Object Relational Data Access) toe aan de 4D-datastore of werkt ze bij. Het volgnummer gaat pas verder na succesvolle verwerking, zodat geen wijziging wordt overgeslagen.
De omgekeerde route gebruikt het triggersysteem van 4D. Alle 62 databasetabellen hebben triggers die bij iedere opslag worden uitgevoerd en de verwerking aan een centrale DataBridge-coördinator doorgeven. Die zet de wijziging als RecordSync-regel in een wachtrij. Het achtergrondproces stuurt deze naar bridge.php, dat de gegevens in MySQL toevoegt of bijwerkt. Na HTTP 200 worden de synchronisatieregels verwijderd.
De triggerarchitectuur gebruikt SharedStorage voor communicatie tussen processen binnen de 4D-runtime. Ontdubbeling voorkomt dat dezelfde recordwijziging bij snel opeenvolgende opslagacties meerdere keren in de wachtrij komt.
De bagageplanning in de desktopapp bevat routeoptimalisatie. Een LuggageRouting-klasse wijst pakketten aan vervoersritten toe. Een koppeling met OpenRouteService vraagt via de ORS API wegafstanden op. Een Routing-klasse gebruikt een 2-opt-verbeteralgoritme om de totale routelengte langs alle tussenliggende stops (tussenstops) te verkleinen.
Het ontwerpsysteem bestaat uit 32 SCSS-deelbestanden, opgebouwd van ontwerpvariabelen via componentlagen naar paginaspecifieke lay-outs. CSS custom properties bevatten alle kleuren, afstanden en typografiewaarden, zodat ontwerpwijzigingen consequent doorwerken. De gecompileerde stylesheet is ongeveer 49 KB.
Interactie wordt verzorgd door 26 JavaScript-modules voor de boekingskalender, het winkelwagenpaneel, productfilters, tijdkeuze voor bagage, partnerprocessen en betalingen. De kalender koppelt FullCalendar.js aan een eigen beschikbaarheidsweergave met gegevens uit de PHP-beschikbaarheidsmatrix. Moment.js verzorgt de datumberekeningen in de browser, afgestemd op de datumcontrole op de server.
AJAX-communicatie tussen frontend en backend gebruikt een vast antwoordformaat. Elk antwoord bevat een status, eventueel een doorverwijs-URL, een reeks HTML-fragmenten met CSS-selectoren voor invoeging in de DOM via jQuery en veldgegevens voor het invullen van formulieren. Daardoor blijft de clientcode uniform: deze verwerkt één antwoordstructuur, ongeacht de aangeroepen serveractie.
Een aparte beheerbackend (office.php) biedt de volledige beheerinterface: gegevensmodellen bekijken en bewerken, prijzen en voorraad beheren, reserveringen en betalingen bekijken en bedrijfsrapporten genereren. De beheeromgeving gebruikt meer dan 65 JSON-schemabestanden, één per tabel. Deze definiëren veldnamen, gegevenstypen, sorteergedrag en zichtbaarheid in lijsten. Ze sturen een dynamische weergave van lijsten en formulieren aan. Een nieuw databaseveld verschijnt daardoor automatisch in de beheerinterface, zonder templatewijzigingen.
Kiewiet heeft geen website met een boekingswidget, maar een volledig bedrijfsplatform dat op specificatie is gebouwd voor een bedrijf met sterke seizoenspieken en weinig ruimte voor fouten. Een dubbel geboekte fiets of gemiste bagagelevering raakt direct aan iemands vakantie. Elke laag, van de routingtabel en synchronisatiesoftware tot de routeoptimalisatie, is voor die praktijk ontworpen en gebouwd.
De technische keuzes, zoals een eigen ORM met controlesommen voor wijzigingsdetectie, een netwerk van paginaobjecten dat in de sessie wordt bewaard en een tweerichtingswijzigingslogboek tussen verschillende runtimes, zijn niet gemaakt om elegant te lijken. Ze zijn gemaakt omdat het bedrijf precies dit gedrag nodig had en geen standaardoplossing dat bood.
Meer werk
TST Terschelling
TransportMaster
TransportMaster