Full-Stack Developer
Field Service Track
Eén jaar. Vijf vakken. Eén eindproject dat alles samenvoegt. Je bouwt DispatchIQ — een intelligent field service dispatch platform — van nul tot productierijp. Als je eindproject niet werkt, ben je niet geslaagd.
Frontend
Development
Van HTML naar een productierijde React applicatie met custom Gantt component. Focus: begrijpen wat je bouwt, niet alleen kopiëren van AI output.
Voordat je een framework leert, moet je begrijpen wat er onder de motorkap gebeurt. Een React app is uiteindelijk HTML, CSS en JS in een browser — als iets kapot gaat, moet je terug kunnen naar de basis.
Je leert hier denken in oorzaken in plaats van symptomen: netwerk, rendering en scripting beïnvloeden elkaar voortdurend. Die systeembenadering maakt je sneller in debugging zodra de planner complexer wordt.
Elke keer dat je een URL intypt, stuurt je browser een HTTP request naar een server. Die server stuurt een response terug. Een request heeft een methode (GET = ophalen, POST = sturen, PUT = updaten, DELETE = verwijderen), een URL, en optioneel een body met data. De response bevat een statuscode (200 = ok, 404 = niet gevonden, 500 = serverfout) en een body, meestal JSON. In DispatchIQ doet de frontend honderden van deze requests per dag: orders ophalen, toewijzingen sturen, stock checken.
// Open DevTools → Network tab terwijl je dit uitvoert fetch('https://jsonplaceholder.typicode.com/todos/1') .then(res => console.log(res.status, res.ok)) // 200, true // POST: data sturen fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ techId: 8, orderId: 42 }) })
Open DevTools (F12) → Network tab. Laad een willekeurige website. Vind een request en beantwoord: wat is de method? Wat is de statuscode? Wat staat er in de Response headers onder Content-Type?
👁 Toon oplossing
content-type: text/html; charset=utf-8.
Voor een API-call (bijv. zoek naar requests met "json" in de naam):
Method = GET of POST, Status = 200, Content-Type = application/json.
Fout? Status 404 = pagina niet gevonden, 500 = serverfout, 401 = niet ingelogd.De DOM (Document Object Model) is de boomstructuur die de browser opbouwt uit jouw HTML. Via JavaScript kan je elk element lezen, aanpassen of verwijderen. document.querySelector('#order-lijst') zoekt het element met id "order-lijst". createElement maakt een nieuw element. appendChild voegt het toe. Dit is de basis van alles wat je in React later gaat doen — React doet eigenlijk hetzelfde, maar automatisch en efficiënter.
// Een element aanmaken en toevoegen const lijst = document.querySelector('#orders'); const item = document.createElement('li'); item.textContent = 'FSM/2026/0042 — Frituur De Gouden Aardappel'; item.style.color = 'green'; lijst.appendChild(item); // Event listener: klik op knop document.querySelector('#laad-btn').addEventListener('click', () => { lijst.innerHTML = ''; // lijst leegmaken });
Open de browser console (F12). Typ: document.title = 'DispatchIQ'. Wat zie je in de browsertab? Typ dan: document.body.style.background = 'red'. Dit is de DOM live manipuleren.
👁 Toon oplossing
document.title = 'DispatchIQ': de browsertab verandert naar "DispatchIQ". De pagina zelf verandert niet.
Na document.body.style.background = 'red': de volledige pagina-achtergrond wordt rood. Dit is een inline CSS-stijl rechtstreeks op het body-element zetten via JavaScript.
Extra: probeer document.querySelectorAll('a').length — dit telt alle links op de pagina. Of document.body.style.background = '' om de rode achtergrond te resetten.Modern JavaScript (ES6+) heeft een aantal patronen die je in elke codebase ziet. const voor waarden die niet wijzigen, let voor variabelen. Arrow functions (() => {}) zijn compact en erven this van de omgeving. Array methodes zoals map (transformeer elk element), filter (selecteer elementen) en reduce (bereken een waarde uit een array) zijn essentieel voor het verwerken van orderlijsten, technicilijsten en stockdata in DispatchIQ.
// De drie array-methodes die je overal ziet const orders = [ { id: 1, status: 'open', duur: 60 }, { id: 2, status: 'gepland', duur: 90 }, { id: 3, status: 'open', duur: 45 }, ]; const openOrders = orders.filter(o => o.status === 'open'); // [1, 3] const namen = orders.map(o => `Order ${o.id}`); // ["Order 1", ...] const totaalMin = orders.reduce((som, o) => som + o.duur, 0); // 195
Plak de code hierboven in de console. Voeg zelf een 4e order toe met status 'open' en duur 120. Bereken het totaal opnieuw met reduce. Verwacht antwoord: 315.
👁 Toon oplossing
const orders = [
{ id: 1, status: 'open', duurMin: 60 },
{ id: 2, status: 'gepland', duurMin: 90 },
{ id: 3, status: 'open', duurMin: 45 },
{ id: 4, status: 'open', duurMin: 120 }, // nieuw
];
const totaalMin = orders.reduce((som, o) => som + o.duurMin, 0);
console.log(totaalMin); // 315
Uitleg: reduce begint met startwaarde 0 (tweede argument). Bij elke iteratie telt het de duurMin van de huidige order bij de lopende som op. Na 4 iteraties: 0 + 60 + 90 + 45 + 120 = 315.fetch() stuurt een HTTP request en geeft een Promise terug — een belofte dat er later data aankomt. Met async/await schrijf je asynchrone code alsof het synchrone code is: je wacht (await) tot de data er is zonder de rest van de pagina te blokkeren. Dit is hoe DispatchIQ alle Odoo-data ophaalt: de pagina blijft reageren terwijl de orders in de achtergrond geladen worden. Altijd error handling toevoegen — netwerken zijn onbetrouwbaar.
async function laadTechnici() { try { const response = await fetch('/api/technicians'); if (!response.ok) throw new Error('HTTP ' + response.status); const data = await response.json(); // JSON string → object return data; } catch (err) { console.error('Laden mislukt:', err.message); return []; } }
Plak dit in de console en voer uit:const r = await fetch('https://jsonplaceholder.typicode.com/users/1'); const data = await r.json(); console.log(data.name, data.email);
Je ziet een echte API-response. Dit is exact hoe DispatchIQ straks data ophaalt.
👁 Toon oplossing
Leanne Graham Sincere@april.biz
Wat er achter de schermen gebeurt:
1. fetch(url) stuurt een HTTP GET request en geeft een Promise terug.
2. await wacht tot de server antwoordt (zonder de pagina te blokkeren).
3. r.json() leest de response body en parsed de JSON string naar een JS object — dit is ook asynchroon, vandaar de tweede await.
4. data.name en data.email zijn gewone object-properties.
Als de fetch faalt (geen internet): de Promise rejected → zonder try/catch zie je een Uncaught error in de console.DevTools is de meest onderschatte tool voor beginners. De Console toont fouten en laat je code uitvoeren. De Network tab toont elke HTTP request met timing, headers en response body — essentieel voor het debuggen van API-calls. De Elements tab toont de live DOM-structuur. Breakpoints in de Sources tab laten je code pauzeren op een specifieke lijn en variabelewaarden inspecteren. Als je DispatchIQ bouwt, zal je de Network tab dagelijks gebruiken om te zien wat de Odoo-API teruggeeft.
Open DevTools → Network tab → filter op "Fetch/XHR". Ga naar een website (bijv. reddit.com). Klik op één van de requests in de lijst. Bekijk de Headers en Response subtabs. Wat is de Content-Type? Kan je de JSON data lezen?
👁 Toon oplossing
api.reddit.com of www.reddit.com/.json.
In de Headers tab:
- Request URL: het volledige adres
- Request Method: GET
- Status Code: 200 OK
In de Response tab: je ziet raw JSON. Als het onleesbaar is, klik op "Preview" — DevTools formatteert de JSON netjes als een boom die je kan uitvouwen.
Pro tip: in de Network tab kan je ook de timing zien (hoelang elke request duurde). De "Waterfall" kolom toont visueel wanneer elke request startte en eindigde.De browser doorloopt stappen om HTML naar pixels te vertalen: Parse (HTML → DOM), Style (CSS berekenen), Layout (posities bepalen), Paint (tekenen). Als je JavaScript de breedte of hoogte van een element opvraagt via offsetWidth terwijl je tegelijk stijlen aanpast, forceert de browser een volledige herberekening — dit noemen we layout thrashing. In de Gantt planner van DispatchIQ, waar tientallen blokken tegelijk bewegen, kan dit de animaties hakkelig maken.
Open DevTools → Performance tab → klik Record → beweeg je muis snel over een website → stop. Zoek naar paarse "Layout" stukken in de tijdlijn. Langere paarse blokken = trage layout herberekeningen.
👁 Toon oplossing
transform: translateX() in plaats van left: — transforms bypassen layout en paint.Een technieker op de baan heeft soms een zwak 4G-signaal. Als DispatchIQ 5 MB JavaScript laadt voor de mobiele view, duurt dat 10+ seconden. Drie technieken helpen: payload minimaliseren (alleen sturen wat nodig is), caching (browser onthoudt bestanden via HTTP headers zodat ze niet opnieuw gedownload worden), en lazy loading (afbeeldingen en code pas laden als ze echt nodig zijn). In Next.js is dit grotendeels automatisch, maar je moet begrijpen waarom.
DevTools → Network tab → rechtsboven klik op het throttle-menu → kies "Slow 3G". Laad een website opnieuw. Hoeveel secondes duurt het? Kijk in de waterval welk bestand het langst duurt. Dit is de ervaring van je technieker op het terrein.
👁 Toon oplossing
.js). Moderne frameworks zoals React produceren snel bestanden van 500KB-1MB.
Wat dit betekent voor DispatchIQ:
- De dispatcher (kantoor, wifi) → geen probleem
- De technieker (bestelwagen, veld, 4G) → PWA met service worker is essentieel
- De service worker cached de JS-bestanden lokaal → na de eerste laadbeurt werkt de app ook offline of op traag netwerk
In Next.js is code-splitting automatisch: elke pagina laadt alleen zijn eigen JS, niet de hele app in één keer.// Basis web-flow voor DispatchIQ orderlijst async function laadOrders() { try { const response = await fetch('/api/orders?status=open'); if (!response.ok) throw new Error('HTTP ' + response.status); const orders = await response.json(); const list = document.querySelector('#order-lijst'); list.innerHTML = ''; orders.forEach(function(order) { const li = document.createElement('li'); li.textContent = order.name + ' - ' + order.location; list.appendChild(li); }); } catch (err) { console.error('Laden mislukt', err); } }
Bouw een pure HTML/JS pagina (geen framework, geen bibliotheken) die een gesimuleerde DispatchIQ orderlijst toont en beheert.
Stap 1: Maak een orders array in JS met 5 objecten: { id, type, technieker, status, duurMin }.
Stap 2: Render de array als een HTML lijst via DOM-manipulatie. Gebruik map + innerHTML.
Stap 3: Voeg een filter toe: knop "Toon enkel open orders" (gebruik filter).
Stap 4: Voeg een "Laad extra orders" knop toe die via fetch data haalt van https://jsonplaceholder.typicode.com/todos?_limit=3 en de items toevoegt aan de lijst. Toon een loading-indicator tijdens het laden en een foutmelding als de fetch faalt.
Stap 5: Toon bovenaan de totale geplande tijd (reduce) en update dit live bij elke filter.
Definitie of done: De pagina werkt volledig zonder fouten in de console. Je kan mondeling uitleggen wat elke lijn doet, waarom je async/await gebruikt en wat er gebeurt als de API niet bereikbaar is.
👁 Toon uitgewerkte oplossing
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<title>DispatchIQ Orders</title>
<style>
body { font-family: sans-serif; padding: 20px; }
#totaal { font-weight: bold; margin-bottom: 12px; }
li { padding: 4px 0; }
#loading { color: gray; display: none; }
#fout { color: red; display: none; }
</style>
</head>
<body>
<h1>DispatchIQ Orders</h1>
<p id="totaal"></p>
<button onclick="filterOpen()">Toon enkel open</button>
<button onclick="renderOrders(orders)">Toon alles</button>
<button onclick="laadExtra()">Laad extra orders</button>
<p id="loading">Bezig met laden...</p>
<p id="fout"></p>
<ul id="lijst"></ul>
<script>
const orders = [
{ id: 1, type: 'vaststelling', technieker: 'Jonas', status: 'open', duurMin: 60 },
{ id: 2, type: 'herstelling', technieker: 'Lena', status: 'gepland', duurMin: 90 },
{ id: 3, type: 'onderhoud', technieker: 'Jonas', status: 'open', duurMin: 45 },
{ id: 4, type: 'vaststelling', technieker: 'Tom', status: 'open', duurMin: 75 },
{ id: 5, type: 'herstelling', technieker: 'Lena', status: 'open', duurMin: 120 },
];
function renderOrders(lijst) {
const ul = document.querySelector('#lijst');
ul.innerHTML = lijst.map(o =>
`<li>#${o.id} — ${o.type} | ${o.technieker} | ${o.status} | ${o.duurMin} min</li>`
).join('');
const totaal = lijst.reduce((som, o) => som + o.duurMin, 0);
document.querySelector('#totaal').textContent = `Totaal gepland: ${totaal} min`;
}
function filterOpen() {
renderOrders(orders.filter(o => o.status === 'open'));
}
async function laadExtra() {
document.querySelector('#loading').style.display = 'block';
document.querySelector('#fout').style.display = 'none';
try {
const r = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=3');
if (!r.ok) throw new Error('HTTP ' + r.status);
const extra = await r.json();
extra.forEach(item => orders.push({
id: item.id + 100, type: 'extern', technieker: '—',
status: item.completed ? 'gepland' : 'open', duurMin: 30
}));
renderOrders(orders);
} catch (err) {
document.querySelector('#fout').textContent = 'Laden mislukt: ' + err.message;
document.querySelector('#fout').style.display = 'block';
} finally {
document.querySelector('#loading').style.display = 'none';
}
}
renderOrders(orders); // initiële render
</script>
</body>
</html>
Valkuilen: vergeet niet finally om de loading indicator altijd te verbergen, ook bij fouten. Gebruik innerHTML = '' om de lijst te resetten vóór elke nieuwe render — anders stapelen de items op.React is een mentaal model, geen magie. Elke UI is een boom van components die elk hun eigen state en props hebben. Als je dit begrijpt, begrijp je elke React codebase.
De focus ligt op componentgrenzen: waar eindigt presentatie en waar start businesslogica. Goede grenzen maken refactoring sneller en voorkomen dat state ongecontroleerd door de app lekt.
In H1 deed je aan imperatief programmeren: je vertelde de browser stap voor stap hoe hij de DOM moest aanpassen (createElement, appendChild). React is declaratief: jij beschrijft wat de UI moet zijn op basis van de huidige data (de "state"), en React zorgt via de Virtual DOM dat de echte browser-DOM efficiënt wordt bijgewerkt. Dit voorkomt 'spaghetti-code' waarbij je handmatig overal elementen moet synchroniseren.
Wat is het grootste voordeel van de Virtual DOM? A) Het maakt de browser sneller. B) Het minimaliseert het aantal trage updates aan de echte DOM. C) Het vervangt HTML volledig.
👁 Toon oplossing
JSX ziet eruit als HTML, maar het is eigenlijk een syntax-extensie voor JavaScript. Omdat het JS is, gebruiken we className in plaats van class (want class is een gereserveerd woord in JS). Tussen accolades { } kun je elke geldige JavaScript-expressie zetten: variabelen, berekeningen of functie-aanroepen. Elk component moet ook één enkel 'parent' element teruggeven, of een Fragment (<>...</>).
const OrderInfo = ({ title, priority }) => { const isUrgent = priority === 'high'; return ( <div className={`order-card ${isUrgent ? 'urgent' : ''}`}> <h3>{title.toUpperCase()}</h3> {isUrgent && <span className="badge">DRINGEND</span>} </div> ); };
Hoe zet je een inline style in JSX voor een achtergrondkleur die uit een variabele bg komt?
👁 Toon oplossing
<div style={{ backgroundColor: bg }}>
Uitleg: De buitenste { } zijn voor de JS-expressie, de binnenste { } maken een object. Merk op dat CSS properties in camelCase worden geschreven (backgroundColor ipv background-color).Props zijn als argumenten van een functie: je krijgt ze van je 'parent' en je mag ze niet aanpassen (read-only). State is het interne geheugen van je component: data die kan veranderen (zoals een geopende tab of een getypt zoekwoord). In React gebruiken we de useState hook om state aan te maken. Zodra de state verandert via de setter-functie, rendert React je component automatisch opnieuw.
import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); // [waarde, setter] return ( <button onClick={() => setCount(count + 1)}> Aantal: {count} </button> ); }
In DispatchIQ: is de 'lijst van alle technici' die uit de API komt State of Props voor het hoofdmenu?
👁 Toon oplossing
Om arrays van data (zoals orders) te tonen, gebruiken we .map(). React eist dat elk item in de lijst een unieke key prop heeft (meestal een ID uit de database). Deze key helpt React te herkennen welke items zijn gewijzigd, toegevoegd of verwijderd. Zonder unieke keys moet React de hele lijst opnieuw tekenen, wat bij een Gantt planner met honderden blokken voor enorme vertraging zorgt.
{technicians.map(tech => (
<TechnicianRow
key={tech.id}
name={tech.name}
/>
))}Waarom is het gebruiken van de array-index (0, 1, 2...) als key een slecht idee als de lijst gesorteerd kan worden?
👁 Toon oplossing
id.De useEffect hook gebruik je voor 'side effects': acties die buiten de rendering vallen, zoals data ophalen (API calls), timers instellen of handmatige DOM-manipulatie. De dependency array (het tweede argument) bepaalt wanneer de effect-functie opnieuw draait. Een lege array [] betekent: draai maar één keer bij het laden van het component. Vergeet de cleanup functie niet voor timers of subscriptions!
useEffect(() => { const controller = new AbortController(); fetchOrders(controller.signal); // API call return () => controller.abort(); // Cleanup: stop fetch als component sluit }, []); // [] = alleen bij mount
Wat gebeurt er als je de dependency array volledig weglaat bij useEffect?
👁 Toon oplossing
Wanneer je dezelfde logica (bijv. data laden met loading state en error handling) in meerdere componenten gebruikt, maak je een Custom Hook. Dit is een gewone functie waarvan de naam begint met use. Hierdoor kun je complexe React-features (zoals state en effects) hergebruiken zonder code te dupliceren. In DispatchIQ bouwen we bijvoorbeeld useOdooQuery om overal op dezelfde manier data op te halen.
function useToggle(initialValue = false) { const [value, setValue] = useState(initialValue); const toggle = () => setValue(!value); return [value, toggle]; } // Gebruik in component: const [isOpen, toggleOpen] = useToggle();
In plaats van één gigantisch component te maken met 20 props, gebruiken we Composition. Via de speciale prop children kan een component andere elementen "omarmen". Dit is hoe we in DispatchIQ een herbruikbaar Modal of Card component maken: de wrapper bepaalt de stijl, de inhoud bepaal je per plek waar je hem gebruikt.
const Panel = ({ title, children }) => ( <div className="panel"> <header>{title}</header> <div className="content">{children}</div> </div> ); // Gebruik: <Panel title="Technicus Info"> <p>Naam: Jonas</p> <SkillBadge skill="Koeltechniek" /> </Panel>
Een goed component is Single Responsibility: het doet één ding goed. Splits presentatie (hoe het eruit ziet) en logica (hoe het werkt) waar mogelijk. Gebruik duidelijke prop-namen en vermijd 'prop drilling' (het doorgeven van props door 5 lagen die ze zelf niet nodig hebben). Dit maakt je code testbaar en begrijpelijk voor je teamgenoten (en AI tools!).
// Een technician card component const TechnicianCard = ({ name, skills, ordersToday }) => { const [expanded, setExpanded] = useState(false); return ( <div className="card"> <h3>{name}</h3> <span>{ordersToday} orders vandaag</span> <button onClick={() => setExpanded(!expanded)}> {expanded ? 'Verberg' : 'Toon'} skills </button> {expanded && <SkillsList skills={skills} />} </div> ); };
Bouw de DispatchIQ sidebar component in React: een lijst van technici met naam, aantal orders vandaag, en een klikbaar detail panel dat hun skills toont. Data staat in een lokale JSON array (nog geen API). Component moet herbruikbaar zijn.
In een Gantt planner heb je state over meerdere componenten: de sidebar, het rooster, de technici. Je moet begrijpen hoe je dit beheert zonder chaos.
Je leert ook conflicten beheersen tussen lokale UI-state en serverstate. Die scheiding voorkomt flicker, race conditions en foutieve planning na trage responses.
Soms hebben twee 'sibling' componenten (bijv. de zijbalk en de kaart) dezelfde data nodig. In React verplaats je de state dan naar hun gemeenschappelijke parent. De parent geeft de data als props naar beneden, en een 'updater' functie ook. Zo blijft er één Single Source of Truth en voorkom je dat componenten uit sync raken.
De 'geselecteerde technicus' wordt getoond in de lijst én op de kaart. Waar moet de selectedTechId state leven? A) In de lijst. B) In de kaart. C) In de pagina-container die beide bevat.
👁 Toon oplossing
Voor complexe state (zoals een hele planning met orders die versleept worden) is useState vaak te beperkt. useReducer werkt met het Action/Dispatch patroon. Je stuurt een "actie" (bijv. {type: 'MOVE_ORDER', id: 5}) naar een "reducer" functie. Die functie is puur: hij krijgt de oude state + actie, en berekent de nieuwe state zonder zij-effecten. Dit maakt je planningslogica voorspelbaar en makkelijk te debuggen.
const [state, dispatch] = useReducer(reducer, initialState); // Een actie uitvoeren: dispatch({ type: 'ASSIGN_TECH', orderId: 42, techId: 7 });
Prop-drilling is het doorgeven van data door 5 lagen componenten die er niets mee doen, alleen om het bij de 6e laag te krijgen. De Context API lost dit op door een 'teleportatie-kanaal' te maken. Je wikkelt je app in een Provider, en elk kind kan de data direct 'opzuigen' met de useContext hook. Ideaal voor globale data zoals de ingelogde dispatcher of het gekozen thema.
const UserContext = createContext(); // In de root: <UserContext.Provider value={currentUser}> <App /> </UserContext.Provider> // Diep in de app: const user = useContext(UserContext);
In React mag je state nooit direct wijzigen (bijv. state.orders.push(newOrder)). React ziet dan namelijk niet dat de data veranderd is en zal niet opnieuw renderen. Je moet altijd een nieuwe kopie maken van de data. We gebruiken hiervoor de spread operator (...) of tools zoals Immer. Dit zorgt ervoor dat de geschiedenis van je state behouden blijft, wat handig is voor 'Undo' functionaliteit.
// FOUT: orders.push(new) // GOED: setOrders([...orders, newOrder]);
Als een dispatcher een order versleept, willen we niet dat hij 2 seconden naar een spinner kijkt terwijl de API antwoordt. Met Optimistic Updates passen we de UI onmiddellijk aan alsof de actie geslaagd is. In de achtergrond sturen we de API call. Mislukt de call? Dan doen we een rollback naar de oude state en tonen een foutmelding. Dit maakt DispatchIQ extreem 'snappy'.
Wat moet je opslaan vóórdat je een optimistic update uitvoert om een rollback mogelijk te maken?
👁 Toon oplossing
In plaats van orders op te slaan als een diepe geneste lijst binnen technici, gebruiken we Normalisatie. We slaan orders op als een object waar de ID de key is: { "order-123": { ...data } }. Dit maakt het opzoeken of updaten van één specifieke order razendsnel (O(1) complexiteit), ongeacht hoe groot de lijst is. Het is hetzelfde principe als een index in een database.
Hoewel Context API krachtig is, kan het bij veel updates leiden tot onnodige re-renders van de hele app. Zustand is een moderne, kleine library die global state bewaart buiten de React component boom. Je kunt heel specifiek selecteren welk deel van de state een component nodig heeft (selectors), waardoor alleen dat component opnieuw rendert bij een wijziging. Voor de performante Gantt chart van DispatchIQ is dit vaak de betere keuze.
const useOrderStore = create((set) => ({ orders: [], addOrder: (order) => set((state) => ({ orders: [...state.orders, order] })), }));
Wat als Dispatcher A een order aan Jonas geeft, terwijl Dispatcher B diezelfde order aan Lena geeft? Je leert technieken zoals Last Write Wins of Versioning/ETags. De backend weigert de update als de versie in de database nieuwer is dan de versie die de frontend probeert te schrijven. De frontend moet dan de nieuwste data ophalen en de gebruiker vragen om de wijziging opnieuw te doen.
// Reducer voor DispatchIQ planning state const initialState = { orders: [], technicians: [] }; function dispatchReducer(state, action) { switch (action.type) { case 'ASSIGN_ORDER': return { ...state, orders: state.orders.map(function(o) { if (o.id !== action.orderId) return o; return { ...o, technicianId: action.technicianId, start: action.start }; }) }; default: return state; } }
Voeg een centrale dispatch state toe aan de sidebar van H2. Als een technieker een order toegewezen krijgt, moet dit zichtbaar zijn in zowel de sidebar als een "planning overzicht" component. Gebruik useReducer. Schrijf de reducer functie zelf uit — geen AI voor dit onderdeel.
Een goede Gantt begint met een correcte tijdas. Voor DispatchIQ betekent dat: minuten exact op het scherm zetten, per technieker een rij renderen, en duidelijk tonen waar blokken starten en eindigen. Zonder dit fundament werkt drag and drop later nooit stabiel.
Je werkt hier met geometrische nauwkeurigheid en visual hierarchy. Kleine fouten in schaal of alignment leiden meteen tot verkeerde dispatchbeslissingen in productie.
Een Gantt chart is in essentie een grafiek waar de X-as de tijd voorstelt. Om een order te tekenen, moet je een tijdstip (bijv. 10:30) omzetten naar een pixel-positie (bijv. 245px). Dit doen we via lineaire interpolatie. Je berekent het percentage van de dag dat verstreken is en vermenigvuldigt dat met de totale breedte van je container.
// Voorbeeld: Dag van 08:00 tot 18:00 (10 uur = 600 min) // Order start om 10:00 (120 min na start) // Container is 1200px breed const x = (120 / 600) * 1200; // = 240px
Als de dag 8 uur duurt en je container 800px breed is, op hoeveel pixels van de linkerkant begint een order die exact in het midden van de dag start?
👁 Toon oplossing
(4/8) * 800 = 400.Wanneer een dispatcher een order 'dropt' op een pixel-positie, moeten we weten welk uur dat is om het op te slaan in Odoo. Dit is de inverse functie van subles 4.1. Je deelt de pixel-positie door de totale breedte en vermenigvuldigt dat met de totale duur van de dag. Dit resultaat tel je op bij het startuur van de dag.
In een Gantt planner krijgt elke technieker een eigen rij (vaak een div met position: relative). De orders binnen die rij worden absoluut gepositioneerd. De left waarde is de starttijd in pixels, en de width is de duur in pixels. Dit zorgt ervoor dat orders onafhankelijk van elkaar kunnen verschuiven zonder de rest van de layout te verstoren.
Zonder gridlijnen is een planner onleesbaar. Je leert hoe je met een eenvoudige map() over een reeks uren (08:00, 09:00...) verticale lijnen tekent. Deze lijnen staan achter de orders. Pro-tip: gebruik pointer-events: none op je gridlijnen zodat ze het slepen van orders niet blokkeren.
In DispatchIQ mag er niet gepland worden tijdens de pauze. We renderen pauzes als statische, grijze blokken die over alle rijen heen lopen. Later in Vak 5 leer je hoe je de code zo schrijft dat de 'drop' van een order geweigerd wordt als deze overlap vertoont met zo'n pauzeblok.
Als je met je muis over de planner beweegt, wil je niet dat React alle 200 orders op de pagina opnieuw gaat berekenen. We gebruiken useMemo om de pixel-berekeningen te 'onthouden'. Pas als de orders zelf veranderen, worden de berekeningen opnieuw uitgevoerd. Dit houdt de UI vloeiend, zelfs bij een drukke planning.
Een klassieke fout: de dispatcher plant om 09:00, maar de technieker ziet 11:00. Odoo werkt intern altijd in UTC. Je leert hoe je datums in de frontend omzet naar de lokale tijdzone van de gebruiker voor weergave, maar ze altijd terugstuurt als UTC naar de API. We gebruiken hiervoor bibliotheken zoals date-fns of dayjs.
Als het in België (zomertijd, UTC+2) 10:00 's ochtends is, wat is dan de UTC tijd die je naar Odoo moet sturen?
👁 Toon oplossing
Gebruik nooit alleen kleur om informatie over te dragen (denk aan kleurenblinde dispatchers). Een 'dringende' order moet niet alleen rood zijn, maar bijvoorbeeld ook een icoontje of een dikkere rand hebben. We leren ook hoe je tooltips toevoegt die verschijnen als je over een order zweeft, voor extra context zonder de UI te vervuilen.
// Tijd naar pixels helper const timeToPixel = (time, dayStart, dayEnd, totalWidth) => { const totalMins = (dayEnd - dayStart) / 60000; // ms naar min const offsetMins = (time - dayStart) / 60000; return (offsetMins / totalMins) * totalWidth; }; // Pixels naar tijd (voor drop positie) const pixelToTime = (px, dayStart, dayEnd, totalWidth) => { const totalMins = (dayEnd - dayStart) / 60000; const offsetMins = (px / totalWidth) * totalMins; return new Date(dayStart.getTime() + offsetMins * 60000); };
Bouw een statische DispatchIQ Gantt: 3 technici, tijdlijn 07:00–19:00, 6 hardcoded werkorders en een pauzeblok. Focus op correcte positionering en leesbaarheid, nog zonder drag and drop.
Nu komt de kern van DispatchIQ: orders slepen van de sidebar naar de planning. Je koppelt pointer events aan je tijdas en zorgt dat blokken niet op elkaar landen. De UI moet snel voelen, maar tegelijk harde planningsregels afdwingen.
Je behandelt drag and drop als transactiestap: valideer eerst, commit dan pas. Daardoor blijft planning consistent, ook bij snelle opeenvolgende acties of netwerkvertraging.
Wanneer een dispatcher een order vastneemt in de zijbalk, start het dragstart event. We gebruiken de dataTransfer API om een 'payload' (meestal de Order ID) mee te geven. In React is het vaak makkelijker om de 'dragged item' in een globale state te zetten. Je leert hoe je visuele feedback geeft (bijv. het item half-transparant maken) zodat de gebruiker ziet dat het slepen gelukt is.
De Gantt rijen zijn je 'drop targets'. Via het dragover event bepalen we over welke technieker de muis zweeft. We moeten het standaardgedrag van de browser blokkeren (e.preventDefault()) om een drop toe te staan. We voegen ook tijdelijke CSS-classes toe (zoals .drag-over) om de rij op te lichten waar de order zal landen.
Een planning waarbij een order start om 09:03 is rommelig. We implementeren Snapping: we ronden de X-positie van de muis af naar het dichtstbijzijnde blok van 15 minuten. Dit zorgt ervoor dat orders altijd mooi 'vastklikken' op het grid, wat de leesbaarheid enorm verhoogt.
// Snapping logica const pixelsPerQuarter = widthOfHour / 4; const snappedX = Math.round(mouseX / pixelsPerQuarter) * pixelsPerQuarter;
Dit is de belangrijkste logica: een technieker kan niet op twee plaatsen tegelijk zijn. Voordat we de drop definitief maken, checken we of de nieuwe tijdstip-range overlap vertoont met bestaande orders in die rij. We tonen een rode rand of een waarschuwing als de dispatcher probeert te 'botsen'.
Order A loopt van 10:00 tot 11:00. Order B wil starten om 10:45 en duurt 30 min. Is er een collision?
👁 Toon oplossing
(startB < endA) && (endB > startA).De standaard HTML5 Drag & Drop API werkt slecht op touchscreens. We stappen daarom over op de Pointer Events API (pointerdown, pointermove, pointerup). Dit werkt universeel voor muis, vinger én stylus. Dit is cruciaal omdat dispatchers DispatchIQ soms op een iPad gebruiken.
Zodra de gebruiker loslaat, verplaatsen we het blok direct in de UI. In de achtergrond sturen we de update naar Odoo. Als de server zegt "fout: technieker is ziek gemeld", vliegen we de order direct terug naar zijn oude plek met een subtiele animatie. Dit voorkomt dat de dispatcher moet wachten op de server.
Echte intelligentie: terwijl de dispatcher nog aan het slepen is, checken we alvast of de technieker de juiste skills heeft voor deze order. We kleuren de rij groen (geldig) of geel (waarschuwing: geen certificaat). Dit bespaart de dispatcher tijd omdat hij niet hoeft te 'gokken'.
Dispatchers werken razendsnel en maken fouten. We implementeren een eenvoudige Action History. Elke succesvolle verplaatsing wordt in een lijst gezet. Met Ctrl+Z kun je de laatste actie ongedaan maken. Dit verhoogt het vertrouwen van de gebruiker in de tool enorm.
// Drop handler met snap + collision check function onDropOrder(rowId, x, order) { const snappedX = Math.round(x / slotWidth) * slotWidth; const start = pixelToTime(snappedX, dayStart, dayEnd, width); const end = new Date(start.getTime() + order.durationMin * 60000); const hasOverlap = assignments.some(function(a) { if (a.rowId !== rowId) return false; return start < a.end && end > a.start; }); if (hasOverlap) return { ok: false, reason: 'Overlapping order' }; return { ok: true, rowId: rowId, start: start, end: end }; }
// Touch support voor mobile dispatcher (pointer events) let dragState = null; orderBlock.addEventListener('pointerdown', function(e) { e.currentTarget.setPointerCapture(e.pointerId); dragState = { order: order, startX: e.clientX, startY: e.clientY }; e.currentTarget.style.opacity = '0.6'; }); document.addEventListener('pointermove', function(e) { if (!dragState) return; // highlight drop target onder cursor const row = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-row]'); document.querySelectorAll('[data-row]').forEach(function(r) { r.classList.remove('drag-over'); }); if (row) row.classList.add('drag-over'); }); document.addEventListener('pointerup', function(e) { if (!dragState) return; const row = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-row]'); if (row) { const rect = row.getBoundingClientRect(); const result = onDropOrder(row.dataset.row, e.clientX - rect.left, dragState.order); if (!result.ok) showWarning(result.reason); } dragState = null; });
setPointerCapture() zodat events blijven binnenkomen ook als de vinger/cursor buiten het element gaat.Activeer drag and drop voor 5 niet-geplande orders. Vereisten: snap op 15 minuten, overlap blokkeren, en visuele status (groen geldig/rood ongeldig) tijdens slepen.
De Gantt component verhuist naar een Next.js project. Je leert hoe Next.js routing werkt, hoe je API routes schrijft, en hoe je de app omzet naar een installeerbare PWA voor de technieker op het terrein.
Deze week draait om productierijpheid: build pipeline, caching, offline gedrag en deployment vallen samen. Je definieert duidelijke grenzen tussen webapp, API-routes en toestelervaring.
Next.js is een framework bovenop React dat routing, SEO en server-side rendering regelt. Met de App Router maak je pagina's door simpelweg mappen en page.tsx bestanden aan te maken. Je leert hoe layout.tsx helpt om de sidebar en navigatie over alle pagina's heen te behouden zonder dat ze opnieuw laden.
In Next.js draait code standaard op de server. Dit is geweldig voor snelheid en veiligheid. Maar voor interactiviteit (zoals onze Gantt chart met drag & drop) hebben we Client Components nodig. Je leert wanneer je de 'use client' directive bovenaan je bestand zet en hoe je data veilig van server naar client doorgeeft.
Een component dat alleen de 'naam van het bedrijf' uit de database toont: Server of Client component?
👁 Toon oplossing
Soms wil je vanuit je frontend een database aanspreken of een geheim (zoals een Odoo wachtwoord) gebruiken zonder dat de gebruiker het ziet. Hiervoor gebruiken we API Routes (route.ts). Deze bestanden draaien alleen op de server en gedragen zich als een volwaardige REST API binnen je Next.js project.
Tailwind is een 'utility-first' CSS framework. In plaats van aparte .css bestanden schrijf je classes direct in je HTML (bijv. className="bg-blue-500 p-4 rounded"). Je leert hoe je hiermee in minuten een responsive layout maakt die werkt op zowel een 27" dispatcher monitor als een 6" technieker smartphone.
Een technieker staat vaak in een kelder zonder bereik. Een Progressive Web App (PWA) kan via een Service Worker bestanden lokaal opslaan (cachen). Je leert hoe je een manifest.json instelt zodat techniekers DispatchIQ als een 'echte' app op hun startscherm kunnen installeren.
Je app is klaar. Hoe krijgt de wereld hem te zien? We 'verpakken' de hele Next.js app in een Docker container. Deze container sturen we naar je Hetzner server. Via Traefik zorgen we dat de app bereikbaar is op een mooi domein met een groen HTTPS-slotje (SSL).
Wanneer de technieker weer bereik heeft, moeten de lokaal opgeslagen werkbonnen naar Odoo gestuurd worden. Je leert hoe je een Sync Queue bouwt die automatisch start zodra navigator.onLine weer op true springt. We behandelen ook hoe je omgaat met conflicten (bijv. als de dispatcher de order al gewijzigd heeft).
Wachtwoorden en API-keys horen nooit in je code (en dus nooit op GitHub). We gebruiken .env bestanden. Je leert het verschil tussen NEXT_PUBLIC_ variabelen (die de browser mag zien) en geheime variabelen (die alleen op de server blijven). Dit is essentieel voor een veilige DispatchIQ productie-omgeving.
// app/layout.tsx + metadata + PWA manifest koppeling export const metadata = { title: 'DispatchIQ Planner', description: 'Dispatcher + technieker PWA', manifest: '/manifest.json' }; // app/api/orders/route.ts export async function GET() { return Response.json({ orders: [] }, { status: 200 }); } // public/manifest.json (kernvelden) { "name": "DispatchIQ Mobile", "short_name": "DispatchIQ", "display": "standalone", "start_url": "/tech" }
De DispatchIQ frontend draait op dispatch.jouwdomein.be via HTTPS. De Gantt werkt met gesimuleerde data. De dispatcher view (desktop) en technieker view (PWA mobile) zijn apart. Code staat op GitHub. Mondeling: leg de component tree uit van sidebar tot Gantt blok.
API &
Backend Development
Node.js, REST APIs, databases, Docker compose, en de cruciale Odoo XML-RPC connectie. De backend is de lijm tussen je frontend en Odoo.
In deze startweek bouw je een backend die voorspelbaar reageert onder load. Je leert hoe Express requests door middleware vloeien, hoe je routes consistent structureert en hoe je fouten centraal afhandelt. Dit vormt de basis voor elke latere Odoo-koppeling.
Je legt ook operationele standaarden vast: naming, responseformats en foutcodes zijn vanaf dag 1 uniform. Die discipline voorkomt integratiefrictie zodra meerdere services samenwerken.
In tegenstelling tot traditionele servers die voor elke bezoeker een apart 'draadje' (thread) openen, werkt Node.js met één Single Thread. Dankzij de Event Loop kan Node.js duizenden gelijktijdige verbindingen aan zonder vast te lopen. Terwijl de database een order zoekt, gaat de Event Loop alvast door naar de volgende request. Dit is perfect voor DispatchIQ, waar veel kleine API calls tegelijk binnenkomen.
Wat gebeurt er als je een zware berekening (bijv. 10 seconden lang getallen optellen) uitvoert in de hoofddraad van Node.js?
👁 Toon oplossing
Express is het meest populaire framework voor Node.js. Je leert hoe je 'endpoints' definieert voor de frontend. We gebruiken Route Parameters (zoals :id) om specifieke orders op te vragen, en Query Strings (zoals ?status=open) om lijsten te filteren. Dit is de brug tussen je frontend-code en je database.
// Een specifieke order ophalen app.get('/api/orders/:id', (req, res) => { const orderId = req.params.id; // Zoek order in DB... res.json({ id: orderId, status: 'open' }); });
Middleware zijn functies die tussen de request en de uiteindelijke route-handler zitten. Je leert hoe je Express.json() gebruikt om data te lezen, CORS om je frontend toegang te geven, en hoe je zelf middleware schrijft voor Logging. Elke request naar DispatchIQ wordt zo eerst gecontroleerd en gelogd voordat hij de database raakt.
REST (Representational State Transfer) is een set afspraken over hoe een API eruit moet zien. Gebruik GET voor ophalen, POST voor aanmaken, PUT/PATCH voor wijzigen en DELETE voor verwijderen. We leren ook de juiste statuscodes te sturen: 201 voor 'Aangemaakt', 400 voor 'Foute invoer' en 404 voor 'Niet gevonden'.
Je bouwt een backend zonder dat er al een frontend is. Postman (of Insomnia) is je beste vriend: het simuleert de frontend. Je leert Collections aanmaken zodat je met één klik al je endpoints kunt testen, en hoe je Environment Variables gebruikt om te switchen tussen lokaal testen en je Hetzner server.
Je database-wachtwoord mag nooit in je code staan. We gebruiken .env bestanden. Je leert hoe de dotenv library deze variabelen inlaadt in process.env. Dit is de eerste stap naar een veilige productie-omgeving: code op GitHub, geheimen alleen op de server.
Waarom moet je .env toevoegen aan je .gitignore bestand?
👁 Toon oplossing
Een goede API is voorspelbaar. We spreken af dat elke foutmelding hetzelfde format heeft (bijv. { "error": "Bericht" }). We leren ook hoe je Paginatie toevoegt aan grote lijsten, zodat je frontend niet crasht als er plots 10.000 orders in Odoo staan.
Voordat je data opslaat in je database, moet je ze controleren. We gebruiken Zod of Joi om schema's te definiëren. Is de techId wel een getal? Is de status wel één van de toegestane waarden? Dit voorkomt dat 'vervuilde' data je systeem crasht of onveilig maakt.
// Minimale Express basis met centrale error handling import express from 'express'; const app = express(); app.use(express.json()); app.get('/api/orders', function(req, res) { res.json([{ id: 1, status: 'open' }]); }); app.post('/api/orders/:id/assign', function(req, res, next) { try { const orderId = Number(req.params.id); if (!orderId) throw new Error('Invalid id'); res.status(200).json({ ok: true, orderId, techId: req.body.techId }); } catch (err) { next(err); } }); app.use(function(err, req, res, next) { res.status(500).json({ error: err.message }); });
Bouw een GET /api/orders en POST /api/orders/:id/assign endpoint met Express. Data staat nog in memory (array). Voeg een logging middleware toe die elke request logt naar requests.log. Test met Postman.
Vanaf deze week maak je je backend stateful en betrouwbaar met PostgreSQL. Je ontwerpt tabellen die passen bij planning, skills en voertuigen en vertaalt die structuur naar Drizzle schema's en migrations. Het doel is traceerbare data in plaats van vluchtige in-memory arrays.
Datamodellering is hier een productbeslissing: schemafouten vertalen zich direct naar planningsfouten. Je oefent daarom met constraints, indexering en duidelijke ownership van tabellen.
PostgreSQL is een 'relationele' database. Dat betekent dat data in tabellen staat die met elkaar verbonden zijn via ID's (Primary & Foreign Keys). Je leert waarom Postgres de standaard is in de industrie: het is extreem betrouwbaar en kan moeiteloos complexe vragen (queries) aan over tienduizenden orders en klanten.
Wat is het verschil tussen een Primary Key en een Foreign Key?
👁 Toon oplossing
order_id kolom in de Tabel 'Onderdelen').Voordat we een tool gebruiken, leren we de basistaal: SQL. Je oefent met SELECT om data te lezen, INSERT om orders toe te voegen, en de krachtige JOIN om data uit meerdere tabellen te combineren (bijv. "toon alle orders én de namen van de bijbehorende technici").
-- Toon orders van technicus met ID 7 SELECT * FROM orders WHERE tech_id = 7 ORDER BY scheduled_at ASC;
In TypeScript willen we niet alleen tekst-strings naar onze database sturen. Drizzle ORM laat ons queries schrijven in TypeScript. Het grote voordeel: als je de naam van een kolom verandert, krijg je overal in je code direct een rode kringel. Geen runtime errors meer door typefouten!
We ontwerpen het hart van onze app: de database-tabellen. Je leert hoe je relaties legt: één technicus heeft vele orders (One-to-Many), en een technicus heeft vele skills (Many-to-Many). Een goed schema is de helft van het werk voor een stabiele app.
Je database-structuur verandert tijdens het bouwen. Migrations zijn kleine scriptjes die deze wijzigingen bijhouden. Je leert hoe je migrations genereert en uitvoert, zodat je database op je laptop altijd exact gelijk is aan de database op de server.
Waarom zou je nooit handmatig tabellen aanpassen via een interface (zoals DBeaver) op je productie-server?
👁 Toon oplossing
Stel: je wijst een order toe aan een technicus én je moet tegelijk een onderdeel uit de voorraad halen. Als het tweede deel mislukt, wil je ook dat de toewijzing niet doorgaat. Transactions zorgen voor een 'atomair' pakketje: ofwel alles slaagt, ofwel er gebeurt helemaal niets.
Als je 100.000 orders hebt, duurt het zoeken op datum lang. Een Index is als een inhoudstafel achteraan een boek: het helpt Postgres om razendsnel de juiste rijen te vinden zonder de hele tabel te moeten doorlezen. We leren waar je indexen plaatst voor maximale winst.
Voor de FixAssistant die we later bouwen, moeten we 'vector data' opslaan. pgvector is een extensie voor Postgres die dit mogelijk maakt. Hiermee kunnen we zoeken op 'betekenis' in plaats van op woorden (bijv. "hoe fix ik een lek?" vindt ook resultaten over "waterverlies").
// Drizzle schema voorbeeld export const orders = pgTable('orders', { id: serial('id').primaryKey(), externalId: varchar('external_id', { length: 64 }), type: varchar('type').notNull(), // vaststelling/repair/... techId: integer('tech_id').references(() => technicians.id), scheduledAt: timestamp('scheduled_at'), durationMin: integer('duration_min').default(60), status: varchar('status').default('open'), });
Vervang de in-memory array van H1 door echte PostgreSQL queries via Drizzle. Schrijf migrations. Voeg een GET /api/technicians/:id/orders?date=2025-09-15 endpoint toe dat alle orders van een technieker op een dag teruggeeft, gesorteerd op scheduled_at.
Nu combineer je alle componenten in een reproduceerbare runtime. Met Docker Compose definieer je netwerk, servicegrenzen en opstartvolgorde zodat je lokaal exact dezelfde stack hebt als op server. Dat verkleint deploy-risico en versnelt foutanalyse.
De kern is determinisme: één commando moet altijd dezelfde stack geven. Je documenteert daarom health criteria en failure modes per service, inclusief herstelpad.
Je hebt al ervaring met Docker, maar we gaan dieper. Je leert hoe je Images bouwt met een Dockerfile die geoptimaliseerd is voor productie (kleine bestandsgrootte, veilig). We herhalen hoe Volumes ervoor zorgen dat je database-data niet verdwijnt als je een container herstart.
In plaats van elke container handmatig te starten, gebruiken we Docker Compose. Met één bestand (docker-compose.yml) beschrijf je de hele DispatchIQ stack: de API, de database, Odoo en de kaartenservice. Je leert hoe Networks ervoor zorgen dat deze containers elkaar veilig kunnen vinden op de server.
Als je container 'api' wil praten met container 'db', welk IP-adres moet de api dan gebruiken?
👁 Toon oplossing
db). Docker regelt de rest via interne DNS.We bouwen een complete microservices-architectuur. Je leert hoe je services definieert die afhankelijk zijn van elkaar. Je frontend (Next.js) praat met je API (Node.js), en die API haalt data uit zowel Postgres als Odoo. Alles draait in zijn eigen veilige container.
Traefik is een 'reverse proxy'. Het staat aan de voordeur van je server en stuurt bezoekers naar de juiste container. Het mooiste? Traefik regelt automatisch SSL-certificaten (HTTPS) via Let's Encrypt. Geen gedoe meer met certificaten die verlopen!
Een API mag pas starten als de database volledig klaar is. We voegen Health Checks toe aan onze Compose-bestanden. Je leert hoe je depends_on gebruikt met de service_healthy conditie. Dit voorkomt dat je API crasht bij het opstarten van de server.
Wat is het gevaar van een API die probeert te verbinden met een database die nog aan het opstarten is?
👁 Toon oplossing
Hoewel we .env gebruiken, leren we ook hoe Docker Secrets werken voor nóg hogere veiligheid. Je leert hoe je wachtwoorden veilig doorgeeft aan containers zonder dat ze ooit zichtbaar zijn in de proceslijst of in logs.
Wat als één container plots alle CPU of RAM van je server opeist? Je leert hoe je Limits instelt in Docker Compose. Hiermee geef je elke service een budget (bijv. max 512MB RAM). Dit zorgt ervoor dat DispatchIQ stabiel blijft draaien, ook als er een bug in één van de services zit.
Een server kan crashen. Je leert hoe je automatische backups maakt van je Docker Volumes. We schrijven een script dat elke nacht een dump maakt van de database en deze veilig opslaat buiten de server. Want een developer zonder backup is geen senior!
# docker-compose.yml fragment services: api: build: ./api environment: - DATABASE_URL=postgresql://dispatch:secret@db:5432/dispatch - ODOO_URL=http://odoo:8069 depends_on: db: condition: service_healthy labels: - "traefik.http.routers.api.rule=Host(`api.dispatch.be`)" - "traefik.http.routers.api.tls.certresolver=letsencrypt"
Schrijf een docker-compose.yml voor DispatchIQ met: frontend, api, postgres, en openrouteservice. Alles draait via Traefik met HTTPS. Health check op postgres. Start de stack op je Hetzner server. Documenteer elk service blok in comments.
XML-RPC is de taal die Odoo spreekt, maar het is een oud en weerbarstig protocol. In een moderne Node.js applicatie wil je niet overal xmlrpc calls zien staan. We passen hier het Gateway/Adapter Pattern toe: we bouwen een eigen klasse die de complexe Odoo-logica verbergt achter simpele methodes als createOrder() of updateCustomer().
De grootste valkuil voor beginners zijn Relational Fields. Hoe update je een One2many lijst (zoals orderregels) in één keer? Odoo gebruikt hiervoor "Magic Tuples" — specifieke commando's die je moet kennen om data relationeel correct weg te schrijven zonder honderden losse calls te doen.
Odoo heeft geen standaard REST API (tenzij met modules). Vanuit Node.js gebruiken we XML-RPC om te praten met Odoo. Je leert hoe je verbinding maakt met de common endpoint voor authenticatie en de object endpoint om data te manipuleren. Het is een stateless protocol: bij elke call stuur je de database-naam en je wachtwoord mee.
Bij elke call naar Odoo kun je een context object meesturen. Dit is cruciaal voor DispatchIQ. Zonder de juiste context (bijv. {'tz': 'Europe/Brussels'}) interpreteert Odoo datums en vertalingen verkeerd. Je leert hoe je dit globaal instelt in je Odoo-client in Node.js.
Waarom is de tz (timezone) parameter in de context zo belangrijk voor een plannings-app?
👁 Toon oplossing
Hoe voeg je in één keer drie onderdelen toe aan een werkorder via de API? Odoo gebruikt hiervoor Magic Tuples (lijsten van 3 getallen). Je leert de belangrijkste commando's: (0, 0, {vals}) om iets nieuws aan te maken, en (6, 0, [ids]) om een hele lijst te vervangen. Dit bespaart je tientallen losse API calls.
// Voorbeeld: vervang alle technici op een project const payload = { tech_ids: [[6, 0, [102, 105]]] // Magic Tuple syntax };
Odoo gebruikt 'Polish Notation' voor filters. In plaats van a AND b schrijf je ['&', a, b]. Je leert hoe je complexe filters bouwt om bijvoorbeeld alleen orders op te halen die "open staan", "vandaag gepland zijn" én "in regio Gent liggen".
Haal nooit meer data op dan nodig. Je leert hoe je search_read gebruikt met de fields parameter. Als je alleen de naam en de datum nodig hebt, vraag dan niet om de volledige omschrijving en alle bijlagen. Dit houdt je DispatchIQ frontend razendsnel.
Wat is het gevaar van een API call zonder limit of fields parameter naar een grote Odoo database?
👁 Toon oplossing
Odoo foutmeldingen zijn vaak cryptisch via XML-RPC. Je leert hoe je deze fouten 'vangt' en vertaalt naar iets begrijpelijks voor je frontend. We maken onderscheid tussen Validatiefouten (bijv. "datum ligt in het verleden") en Systeemfouten (bijv. "Odoo is offline").
Soms weet je niet exact welke velden een model heeft. We gebruiken de methode fields_get om de 'metadata' op te vragen. Zo ontdek je of een veld verplicht is, wat de technische naam is, en welke opties er in een dropdown (Selection field) zitten.
In DispatchIQ praten we meestal met één 'Admin' gebruiker naar Odoo. Dit is krachtig maar gevaarlijk. Je leert hoe je in je Node.js backend toch controles inbouwt, zodat een technieker via jouw API niet per ongeluk (of expres) de facturen van een andere klant kan inzien.
// Modern Odoo Gateway met Magic Tuple helpers import xmlrpc from 'xmlrpc'; // Odoo Write Commands (Magic Tuples) export const Command = { create: (vals) => [0, 0, vals], update: (id, vals) => [1, id, vals], delete: (id) => [2, id], link: (id) => [4, id], clear: () => [5], set: (ids) => [6, 0, ids] }; export class OdooClient { constructor(config) { this.config = config; this.uid = 0; } async connect() { // Authenticate logic... returns UID } async createCustomerWithContacts(name, contactNames) { const contacts = contactNames.map(cName => Command.create({ name: cName, type: 'contact' }) ); return await this.execute('res.partner', 'create', [{ name: name, is_company: true, child_ids: contacts // One2many write in 1 call! }]); } async execute(model, method, args, kwargs = {}) { const context = { lang: 'nl_BE', tz: 'Europe/Brussels', ...kwargs.context }; try { return await this.client.methodCall( 'execute_kw', [this.config.db, this.uid, this.config.password, model, method, args, { ...kwargs, context }] ); } catch (err) { if (err.faultCode) throw new Error(`Odoo Error: ${err.faultString}`); throw err; // Network error } } }
// Gantt data laden: open fsm.orders voor één dag async function getGanttData(dateStr) { // dateStr = '2026-04-08' const domain = [ ['scheduled_date_start', '>=', dateStr + ' 00:00:00'], ['scheduled_date_start', '<=', dateStr + ' 23:59:59'], ['stage_id.is_closed', '=', false], ]; return client.execute('fsm.order', 'search_read', [domain], { fields: ['name', 'person_id', 'scheduled_date_start', 'duration', 'stage_id', 'location_id', 'description'], limit: 100, order: 'scheduled_date_start asc' }); } // Toewijzing terugschrijven naar Odoo na Gantt drag async function assignOrder(orderId, techId, scheduledStart) { await client.execute('fsm.order', 'write', [[orderId], { person_id: techId, scheduled_date_start: scheduledStart // '2026-04-08 08:30:00' }]); await client.execute('fsm.order', 'message_post', [[orderId]], { kwargs: { body: 'Ingepland via DispatchIQ planner' } }); }
scheduled_date_start schrijft, gebruik dan altijd UTC. Lokale tijd (Europe/Brussels = UTC+2 in zomer) omzetten voor het wegschrijven — anders staan alle orders 2 uur verkeerd in de Gantt.Schrijf een Node.js script dat verbindt met je Odoo. Maak in één enkele API call een nieuw Bedrijf aan met daaronder 2 Contactpersonen (gebruik Command.create / tuple (0,0,vals)). Lees het bedrijf daarna terug en log de ID's van de aangemaakte contacten.
Tussen frontend en Odoo plaats je een API proxy die requests normaliseert en logt. Daardoor kan je fouten sneller debuggen, endpointverbruik meten en slim cachen. Voor DispatchIQ is dit essentieel om production issues op Hetzner te kunnen analyseren.
Je ziet logging als productfeature: zonder traceability is incidentrespons traag en duur. Daarom ontwerp je logs meteen voor analyse, niet alleen voor menselijke leesbaarheid.
Waarom praten we niet direct vanuit React met Odoo? Omdat we controle willen. Een API Proxy vangt de request van de frontend op, voegt authenticatie toe, filtert onnodige data weg, en stuurt het dan pas door naar Odoo. Dit maakt je frontend lichter en je architectuur veiliger.
console.log is niet genoeg voor productie. Je leert werken met Winston. Hiermee log je requests naar zowel de console (voor dev) als naar .log bestanden (voor later). Je leert ook het belang van Log Levels: info voor normale acties, error voor crashes en debug voor details.
logger.info('Order 123 verplaatst door gebruiker Jonas', { oldTech: 5, newTech: 8 });
Soms stuurt de frontend data in een formaat dat Odoo niet begrijpt (bijv. een datum als tekst). Je leert hoe je middleware schrijft die deze data intercepteert en omvormt voordat de uiteindelijke Odoo call wordt uitgevoerd. Dit houdt je frontend-code 'clean'.
Odoo geeft vaak gigantische objecten terug. Je leert hoe je de response van Odoo inkort tot alleen de velden die je frontend echt nodig heeft (Data Thinning). Dit bespaart bandbreedte en maakt je app sneller op mobiele netwerken.
Wat is het voordeel van het hernoemen van velden zoals x_studio_technician_id naar simpelweg techId in je proxy?
👁 Toon oplossing
Wat als een bug in de frontend plots 100 requests per seconde naar Odoo stuurt? Je leert Rate Limiting implementeren. Hiermee blokkeer je tijdelijk gebruikers die te veel requests doen. Dit voorkomt dat je Odoo-omgeving (en je hele bedrijf) plat komt te liggen.
Als er een fout optreedt, wil je weten welke API call die veroorzaakte. Je leert hoe je elke request een unieke Correlation ID geeft. Deze ID wordt in alle logs meegenomen. Zo kun je in duizenden regels logbestand exact het pad van één specifieke fout volgen.
Hoe lang duurt een gemiddelde Odoo call? Je leert hoe je de uitvoeringstijd van requests meet en logt. Hiermee ontdek je 'bottlenecks' in je systeem: calls die langer dan 500ms duren vallen op en kunnen we later optimaliseren met caching.
Wachtwoorden en bankrekeningnummers horen nooit in je logbestanden te staan. Je leert hoe je je logger zo instelt dat hij automatisch bepaalde velden 'maskeert' (bijv. verandert in ***) voordat ze opgeslagen worden. Essentieel voor GDPR compliance.
// Express middleware: call logging + trace id app.use(function(req, res, next) { const start = Date.now(); const traceId = crypto.randomUUID(); req.traceId = traceId; res.on('finish', function() { logger.info({ traceId: traceId, method: req.method, path: req.originalUrl, status: res.statusCode, durationMs: Date.now() - start }); }); next(); });
Voeg proxy-logging toe aan je API met trace-id en duurmeting. Toon in een admin endpoint de 50 laatste Odoo-calls gesorteerd op responstijd.
In het sluitstuk van Vak 2 maak je DispatchIQ real-time en route-aware. WebSockets zorgen dat planningswijzigingen direct verschijnen bij andere clients, terwijl OpenRouteService nauwkeurige reistijden levert voor betere toewijzingen en ETA's.
Je combineert eventgedreven architectuur met route-intelligentie. Het doel is niet alleen snelheid, maar ook consistente state bij meerdere gelijktijdige dispatchers.
Een gewone API werkt met 'request/response'. Maar als Dispatcher A een order verplaatst, wil je dat Dispatcher B dat onmiddellijk ziet zonder de pagina te vernieuwen. We gebruiken WebSockets (via Socket.io) om een open verbinding te houden. Zodra de server een update van Odoo krijgt, 'pusht' hij die direct naar alle actieve frontends.
Je leert hoe je eigen events definieert, zoals order:updated of tech:location. We gebruiken Rooms om updates alleen te sturen naar mensen die er iets aan hebben (bijv. alle dispatchers van regio Antwerpen). Dit bespaart data en batterij op de smartphones van techniekers.
// Server-side broadcast io.to('dispatchers').emit('order:moved', { id: 42, newTime: '2026-03-12T10:00:00Z' });
Om een slimme planning te maken, moeten we weten hoe lang een technieker onderweg is. We koppelen de OpenRouteService (ORS) API. Hiermee bereken we de reistijd tussen de huidige locatie van een technieker en het adres van de volgende klant, rekening houdend met het type voertuig.
De Matrix API van ORS is krachtig: hiermee bereken je in één call de reistijd van 10 techniekers naar 1 nieuwe order. Je leert hoe je deze data gebruikt om de dispatcher een suggestie te doen: "Jonas is het dichtstbij (12 min reistijd)".
Waarom gebruiken we reistijd in minuten in plaats van afstand in kilometers voor de planning?
👁 Toon oplossing
Odoo slaat adressen op als tekst (bijv. "Kerkstraat 1, Gent"). Maar voor een kaart hebben we breedte- en lengtegraad (GPS) nodig. Je leert hoe je de Forward Geocoding API gebruikt om adressen om te zetten naar coördinaten en deze op te slaan in je eigen Postgres database voor sneller hergebruik.
Een technieker vergeet vaak op 'Aangekomen' te klikken in de app. Met Geofencing detecteren we automatisch wanneer de GPS van de technieker binnen een straal van 100 meter van het klantadres komt. We sturen dan een WebSocket-bericht naar de dispatcher en updaten de status in Odoo.
We bouwen een dashboard waar de dispatcher alle voertuigen 'live' ziet rijden op de kaart. Je leert hoe je GPS-updates van de mobiele app verwerkt in de backend en vloeiend animeert op de kaart in de frontend via WebSockets.
Als DispatchIQ groeit naar honderden dispatchers, kan één Node.js server alle WebSocket-verbindingen niet meer aan. Je leert hoe je Redis Pub/Sub gebruikt om berichten te delen tussen meerdere API servers. Zo blijft je real-time systeem stabiel, hoe groot je bedrijf ook wordt.
// Socket event + ORS matrix call io.on('connection', (socket) => { socket.on('order:moved', (payload) => { socket.to('dispatchers').emit('order:updated', payload); }); }); async function getTravelMatrix(coords) { const res = await fetch('http://ors:8082/ors/v2/matrix/driving-car', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ locations: coords, metrics: ['duration'] }) }); if (!res.ok) throw new Error('ORS unavailable'); const data = await res.json(); return data.durations; }
De API proxy draait op Hetzner, leest echte fsm.order data, en schrijft toewijzingen terug naar Odoo. WebSocket connectie: als je een order versleept in Gantt, verschijnt de wijziging in een tweede browser tab zonder refresh. ORS geeft echte reistijd tussen twee Belgische adressen terug.
Odoo
Basis
Odoo als businesssoftware begrijpen. Datamodel kennen. Field Service workflow van klant tot factuur. Dit is de fundament voor Vak 4 en 5.
Je start met een stabiele Odoo-omgeving die je later veilig kan uitbreiden. Deze week draait om installatiekeuzes, basisconfiguratie en navigatiebegrip zodat je in volgende hoofdstukken sneller kunt modelleren en debuggen.
De nadruk ligt op reproduceerbaarheid: elke student moet exact dezelfde omgeving kunnen opstarten, resetten en opnieuw vullen met testdata. Je documenteert daarom al vanaf dag 1 je setupbeslissingen en rollback-stappen.
Odoo is beschikbaar in verschillende versies. Community is volledig gratis en open source, maar mist geavanceerde functies zoals Accounting of Studio. Enterprise is de betaalde versie met alle features. Voor DispatchIQ gebruiken we de kracht van de OCA (Odoo Community Association): een enorme verzameling gratis, kwalitatieve modules die de Community-versie uitbreiden met professionele Field Service functionaliteit.
Welke versie van Odoo is het meest geschikt voor een start-up die volledige controle wil over de broncode zonder licentiekosten?
👁 Toon oplossing
Odoo handmatig installeren op Linux is complex door alle Python-dependencies. Met Docker draai je Odoo in 30 seconden. Je leert hoe je een odoo.conf bestand maakt en hoe je Volumes koppelt aan je container. Hierdoor blijven je geïnstalleerde apps en database-data bewaard, ook als je de container verwijdert.
Odoo is modulair. Voor DispatchIQ installeren we de basis-apps: Sales (voor offertes), Inventory (voor onderdelen), en natuurlijk de Field Service base module. Je leert dat modules afhankelijk zijn van elkaar: je kunt Field Service niet installeren zonder dat Odoo automatisch ook de benodigde CRM of Project modules binnenhaalt.
Elke Odoo-omgeving begint bij het bedrijfsobject (res.company). Je stelt je testbedrijf "TechniCool NV" in: logo, BTW-nummer, adres en standaard valuta. Dit is essentieel omdat deze data later automatisch op alle werkbonnen en facturen zal verschijnen.
De Odoo interface is krachtig. Je leert werken met de Search Bar: hoe filter je op "alle orders van vandaag"? Hoe groepeer je orders per technieker? Je leert ook hoe je deze filters opslaat als 'Favoriet' zodat je ze met één klik kunt oproepen op je dashboard.
Niet iedereen mag alles zien. Je maakt drie types gebruikers aan: de Dispatcher (mag plannen), de Technieker (ziet alleen eigen taken) en de Klant (kan status volgen via de portal). Je leert hoe Access Rights en Record Rules bepalen wie welke data mag bewerken.
Een database-crash is de nachtmerrie van elke planner. Je leert hoe je via de /web/database/manager handmatig en automatisch backups maakt van je database (een .zip bestand met de SQL dump én alle bijlagen). We oefenen ook een Restore: het terugzetten van een backup op een lege container.
Bevat een standaard Postgres SQL-dump ook de foto's die techniekers hebben geüpload naar Odoo?
👁 Toon oplossing
Foutmeldingen in Docker logs kunnen intimiderend zijn. Je leert systematisch zoeken naar de oorzaak: is het een poortconflict (poort 8069 al in gebruik)? Kan Odoo de database niet vinden? Of ontbreekt er een Python library in je image? Je bouwt je eigen 'Runbook' om deze problemen in 5 minuten op te lossen.
# docker-compose fragment voor Odoo 17 + Postgres services: odoo: image: odoo:17.0 depends_on: - db ports: - 8069:8069 volumes: - ./odoo-data:/var/lib/odoo - ./addons:/mnt/extra-addons db: image: postgres:15 environment: - POSTGRES_DB=postgres - POSTGRES_USER=odoo - POSTGRES_PASSWORD=odoo volumes: - ./pg-data:/var/lib/postgresql/data
Odoo 17 Community draait op Hetzner via Docker Compose. TechniCool NV is aangemaakt met 3 technici (gebruikers), Inventory + Field Service geïnstalleerd. Je kan inloggen als dispatcher en als technieker. Exporteer de gebruikerslijst naar CSV.
Odoo's kracht én complexiteit zit in het datamodel. Alles is een model met velden. Relaties zijn Many2one, One2many, Many2many. Als je dit begrijpt, kan je élk Odoo scherm lezen en elke API call schrijven.
Je leert ook modelgrenzen respecteren: niet elk probleem los je op met een nieuw veld. Goede keuzes in modelverantwoordelijkheid bepalen of je later eenvoudig kan integreren met planner, facturatie en rapportering.
In Odoo is alles een Model (bijv. res.partner voor klanten). Elk model heeft Fields. Je leert de basistypes: Char voor korte tekst, Integer voor getallen, en Selection voor dropdown-lijstjes. Elk veld dat je aanmaakt in Odoo wordt automatisch een kolom in je PostgreSQL database.
Het res.partner model is het meest gebruikte model in Odoo. Alles is een partner: een klant, een leverancier, een technicus en zelfs een bedrijfsadres. Je leert hoe de hiërarchie werkt (parent/child relaties) om contactpersonen aan een bedrijf te koppelen.
Dit is de kern van Odoo. Een Many2one veld koppelt een record aan één ander record. Een One2many veld toont een lijst van gekoppelde records. Je leert hoe deze velden de database-JOINs regelen die je in Vak 2 hebt geleerd.
Welk veldtype gebruik je om aan een Technicus een lijst van 5 verschillende Skills (zoals 'Electriciteit', 'Loodgieter') te koppelen?
👁 Toon oplossing
Je leert het verschil tussen product.template (het concept van een product) en product.product (de specifieke variant). We kijken ook naar stock.quant: het model dat bijhoudt hoeveel van een product er op welke locatie ligt (magazijn vs auto van de technicus).
Als developer werk je altijd in 'Developer Mode' (met de kever 🪲 in de toolbar). Hiermee kun je over elk veld hoveren om de technische naam te zien (bijv. partner_id), views inspecteren en External ID's vinden. Deze ID's heb je nodig om later data te linken vanuit je Node.js API.
Odoo's kracht is dat je niets van nul moet bouwen. Via Inheritance (overerving) 'leen' je een bestaand model (bijv. de Klant) en voeg je er gewoon je eigen velden aan toe (bijv. 'GPS-coördinaten'). Je leert hoe dit werkt via de interface in deze week.
In Odoo is een Domain een lijst van filters. Je leert hoe je complexe zoekopdrachten maakt, zoals "toon alle partners die een bedrijf zijn EN in Gent wonen". We oefenen de speciale operators zoals child_of om alle werknemers van een hoofdkantoor te vinden.
We duiken in de echte PostgreSQL database van Odoo. Je leert hoe Odoo tabelnamen maakt (meestal door punten te vervangen door underscores: res.partner wordt res_partner). Je leert ook hoe Many2many relaties worden opgeslagen in een aparte 'tussen-tabel'.
// Modelinspectie via XML-RPC search_read const fields = ['name', 'company_type', 'parent_id']; const domain = [['is_company', '=', true]]; const partners = await odoo.execute_kw( db, uid, password, 'res.partner', 'search_read', [domain], { fields: fields, limit: 20 } ); partners.forEach(function(p) { console.log(p.name, p.company_type, p.parent_id); });
Teken het volledige DispatchIQ datamodel in dbdiagram.io of Mermaid: alle Odoo modellen die DispatchIQ gebruikt, hun velden, en relaties. Leg mondeling uit wat stock.quant.location_id is en waarom voertuigen een locatie zijn in Odoo.
Je configureert de volledige horeca field service workflow in Odoo Community + OCA. Dit is de workflow die DispatchIQ beheert via de API.
De diepte zit hier in dataconsistentie tussen klant, locatie en equipment. Dispatchers mogen niet moeten gokken; elke order moet direct voldoende context bevatten om correct te plannen zonder extra telefoons.
Odoo maakt een slim onderscheid tussen bedrijven (Is a Company = True) en individuen. Je leert hoe je contactpersonen koppelt aan een hoofdkantoor, hoe Adresovererving werkt (zodat wijzigingen aan het bedrijf automatisch doorvloeien naar de contacten) en hoe Odoo BTW-nummers valideert via de VIES API.
In de Field Service module (fsm) werken we met fsm.location (de fysieke plek) en fsm.equipment (de machine). Je leert hoe je per klant meerdere locaties beheert (bijv. 'Keuken Gent' en 'Keuken Antwerpen') en hoe je elk toestel koppelt aan een serienummer en merk voor snelle herkenning.
Waarom koppelen we een order aan een fsm.location en niet direct aan een res.partner?
👁 Toon oplossing
Een 'Herstelling' heeft een andere flow dan een 'Onderhoud'. Je leert hoe je Order Types configureert met hun eigen kleuren en prioriteiten. We kijken ook naar Stages (New, In Progress, Done): de statussen waar een order doorheen loopt op weg naar de factuur.
Een technieker is in Odoo een fsm.person (gekoppeld aan een gebruiker). Je leert hoe je hun skills en werkuren instelt. We koppelen ook een fleet.vehicle aan de technieker, omdat het voertuig in DispatchIQ fungeert als een rijdend magazijn met eigen voorraad.
We doorlopen de hele keten: de dispatcher maakt een order aan, plant deze in op de Gantt (die jij bouwt!), de technieker registreert zijn uren en verbruikte onderdelen, en Odoo genereert aan het einde automatisch een Sale Order klaar voor facturatie.
Elke keer dat een onderdeel uit de bestelwagen wordt gehaald, moet Odoo dit weten. Je leert hoe de fieldservice_stock module automatisch Stock Pickings aanmaakt. Zo blijft je voorraad in Odoo altijd synchroon met de werkelijkheid in het voertuig.
Een lekkende vriezer heeft meer haast dan een piepende deur. Je leert hoe je SLA (Service Level Agreements) instelt in Odoo. Wanneer een kritieke panne binnenkomt, kleurt de order in DispatchIQ rood en krijgt de dispatcher een waarschuwing als de deadline nadert.
Garbage in, garbage out. Je leert hoe je velden verplicht stelt (bijv. "geen handtekening = geen factuur"). We bekijken hoe Validation Rules in Odoo ervoor zorgen dat techniekers alle nodige data invullen voordat ze de interventie kunnen afsluiten.
// Voorbeeld payload voor fsm.order creatie { "name": "FSM/2026/0042", "type": "vaststelling", "location_id": 17, "equipment_id": 93, "person_id": 8, "scheduled_date_start": "2026-04-08 08:30:00", "duration": 90, "description": "Blast chiller koelt niet onder 9C" } // Daarna stage zetten en parts registreren via stock moves
Simuleer een volledige field service case: Frituur De Gouden Aardappel belt, hun Electrolux blast chiller (serienr. EBC2024-001 bij locatie Antwerpsesteenweg 45) is kapot. Maak de vaststelling aan, plan in voor technieker Jonas, registreer 1x condensator verbruikt van Jonasʼ voertuig, maak de factuur aan. Screenshot elke stap.
De dispatcherflow wordt nu volledig operationeel gemaakt in Odoo Field Service. Je leert hoe stages, ordertypes en werkbongegevens het ritme van DispatchIQ bepalen, zodat planning en facturatie op dezelfde data draaien.
Een stageflow is pas bruikbaar als overgangen gecontroleerd zijn. Je definieert daarom welke velden verplicht worden per fase en wanneer automatische acties mogen triggeren om handmatig werk te beperken.
Niet elke interventie is hetzelfde. Een 'Vaststelling' (diagnose) heeft vaak geen onderdelen nodig, terwijl een 'Herstelling' dat wel heeft. Je leert hoe je per Order Type een eigen workflow instelt. Dit bepaalt welke schermen de technieker in zijn app te zien krijgt.
Stages zijn de kolommen in je Kanban-bord (Nieuw, Ingepland, Onderweg, Ter Plaatse, Gedaan). Je leert hoe je deze statussen configureert en hoe je Odoo dwingt om bepaalde data te vragen bij een overgang (bijv. "je kunt niet op 'Gedaan' klikken zonder een verslag te typen").
Welke stage-overgang is het meest cruciaal voor de dispatcher om te weten dat hij een nieuwe taak kan toewijzen?
👁 Toon oplossing
Voor DispatchIQ zijn twee velden in Odoo heilig: scheduled_date_start en person_id. Je leert hoe deze velden de basis vormen voor je Gantt chart. We kijken ook naar het veld duration (geschatte tijd), dat bepaalt hoe breed het blokje op je tijdlijn wordt.
De technieker voegt producten toe aan zijn order. Je leert hoe Odoo dit koppelt aan de stock: elk onderdeel dat hij toevoegt, moet "verhuizen" van de locatie 'Voertuig' naar de virtuele locatie 'Klant'. We configureren de automatische aanmaak van een Stock Picking bij het afsluiten van de order.
Een goede werkbon bevat meer dan alleen tekst. Je leert hoe je Binary Fields gebruikt voor foto's van de herstelling en voor de digitale handtekening van de klant. Deze data wordt opgeslagen als ir.attachment in Odoo, gelinkt aan de specifieke order.
Zodra de technieker klaar is, moet er geld verdiend worden. Je leert hoe je met één druk op de knop een Invoice (account.move) genereert op basis van de geregistreerde uren en onderdelen. We kijken ook naar de 'Invoice Status' van een order om te zien of alles al betaald is.
Je leert hoe je Python-foutmeldingen (UserError) triggert in Odoo. Bijv: "U kunt deze order niet voltooien omdat er nog geen onderdelen zijn geregistreerd". Dit voorkomt dat de backoffice later data moet gaan 'opkuisen' of corrigeren.
Waarom is het beter om de validatie in Odoo te doen in plaats van alleen in de DispatchIQ frontend?
👁 Toon oplossing
Wanneer een technieker een probleem meldt (bijv. "toestel is total-loss"), moet de dispatcher dit weten. Je leert hoe je een Next Activity aanmaakt: een taak die automatisch op het dashboard van de dispatcher verschijnt met de juiste prioriteit en deadline.
# Stage overgang op fsm.order stage_open = env['fsm.stage'].search([('name','=','Open')], limit=1) stage_done = env['fsm.stage'].search([('name','=','Afgewerkt')], limit=1) order = env['fsm.order'].browse(42) order.stage_id = stage_open.id order.person_id = 8 order.duration = 90 order.stage_id = stage_done.id order.message_post(body='Werk afgerond, klaar voor facturatie')
Configureer stages en ordertypes zodat een techniekerorder van open naar facturatie loopt zonder handmatig data over te typen tussen modules.
Voertuigvoorraad is cruciaal voor first-time-fix in field service. In deze week vertaal je magazijnlogica naar mobiele stocklocaties, met duidelijke minimumvoorraden en automatische bevoorrading. Zo vermijd je onnodige tweede interventies.
Je optimaliseert niet alleen techniek, maar ook operationele kost: te weinig stock veroorzaakt herbezoeken, te veel stock blokkeert cash. Daarom werk je met meetbare replenishmentregels per voertuigtype.
Odoo organiseert alle voorraad in een locatieboom. De root is het bedrijf, daaronder hangen magazijnen, en daaronder kan je mobiele locaties hangen zoals bestelwagens of klantlocaties. Een interne transfer verplaatst stock van één tak naar een andere. Voor DispatchIQ maak je drie niveaus: WH/Magazijn → VEH/Bestelwagen-Jonas → Klant/FrituurDeGoudenAardappel. Multi-step routes (2-step of 3-step) laten je packing en kwaliteitscontrole inlassen voor onderdelen het voertuig verlaten.
# XML-RPC: locatieboom opvragen voor DispatchIQ dashboard locations = models.execute_kw(db, uid, password, 'stock.location', 'search_read', [[ ['usage', 'in', ['internal', 'transit']], ['active', '=', True] ]], {'fields': ['name', 'complete_name', 'location_id', 'usage']} ) # complete_name geeft het volledige pad: "WH/VEH/Jonas-Bestelwagen" for loc in locations: print(loc['complete_name'])
Ga in Odoo naar Voorraad → Configuratie → Locaties. Maak een locatie VEH/Jonas aan als kind van je magazijn met usage = Internal. Wat zie je bij complete_name?
👁 Toon oplossing
Jonas, Bovenliggende locatie: WH/Stock (of je magazijn), Type: Intern
3. Opslaan. complete_name toont: WH/Stock/Jonas
4. Dit is de locatie die je koppelt aan fleet.vehicle van Jonas.
Tip: zet "Is een Verpakkingslocatie" uit — dit is geen packing zone maar een mobiel magazijn.Standaard Odoo heeft geen directe link tussen fleet.vehicle en stock.location. De OCA module fieldservice_stock voegt het veld stock_location_id toe aan het voertuig. Eenmaal gekoppeld kan DispatchIQ via de voertuig-ID direct de locatie opzoeken en stock.quant records uitlezen. Zo weet de constraints engine exact hoeveel van elk onderdeel op de bestelwagen van Jonas zit voordat een order wordt toegewezen.
# Voertuig opzoeken en stocklocatie uitlezen vehicle = models.execute_kw(db, uid, password, 'fleet.vehicle', 'search_read', [['driver_id.name', '=', 'Jonas Vermeersch']], {'fields': ['name', 'license_plate', 'stock_location_id']} )[0] location_id = vehicle['stock_location_id'][0] # (id, 'WH/Jonas') # Nu stock ophalen op dat voertuig quants = models.execute_kw(db, uid, password, 'stock.quant', 'search_read', [['location_id', '=', location_id]], {'fields': ['product_id', 'quantity', 'reserved_quantity']} )
Koppel in Odoo het voertuig van Jonas aan de locatie WH/Stock/Jonas. Voeg daarna manueel 5 stuks Compressorventiel R32 toe via Voorraad → Operaties → Aanpassen. Lees de quant via XML-RPC uit.
👁 Toon oplossing
search_read('stock.quant', [['location_id.complete_name','=','WH/Stock/Jonas']], ['product_id','quantity'])
→ geeft [{'product_id': [42, 'Compressorventiel R32'], 'quantity': 5.0}]stock.warehouse.orderpoint is de Odoo naam voor een min/max regel. Je stelt per product + locatie een minimum en maximum in. Zodra de voorraad onder het minimum zakt, genereert de scheduler automatisch een interne transfer van het magazijn naar het voertuig. In DispatchIQ is dit de basis van de "replenishment check" die elke ochtend draait: welke bestelwagens moeten worden aangevuld voor de dagrit?
# Min/max regel aanmaken voor Jonas zijn bestelwagen orderpoint_id = models.execute_kw(db, uid, password, 'stock.warehouse.orderpoint', 'create', [{ 'name': 'Jonas / Compressorventiel R32', 'product_id': product_id, # id van het product 'location_id': vehicle_loc_id, # voertuiglocatie 'product_min_qty': 2, # bij < 2 → aanvullen 'product_max_qty': 8, # aanvullen tot 8 'qty_multiple': 1, }] ) # Scheduler handmatig triggeren (in productie draait dit als cron) models.execute_kw(db, uid, password, 'stock.warehouse.orderpoint', 'action_replenish', [[orderpoint_id]] )
Maak een min/max regel voor Compressorventiel R32 op Jonas zijn bestelwagen: min=2, max=8. Verlaag daarna de stock naar 1. Trigger de scheduler. Welke operatie wordt aangemaakt?
👁 Toon oplossing
De fleet module beheert voertuigen met kenteken, chauffeur, brandstoftype, onderhoudsmomenten en verzekering. Voor DispatchIQ is het rijzone-systeem cruciaal: je voegt tags toe zoals LEZ-Antwerpen, LEZ-Gent of Koelinstallatie-gecertificeerd. De constraints engine leest deze tags bij elke toewijzing om te controleren of het voertuig de opdracht legaal en technisch kan uitvoeren.
# Tags opvragen van alle voertuigen voor constraints check vehicles = models.execute_kw(db, uid, password, 'fleet.vehicle', 'search_read', [['active', '=', True]], {'fields': ['name', 'license_plate', 'tag_ids', 'driver_id']} ) # Check: heeft voertuig toegang tot LEZ-zone? def can_enter_lez(vehicle, zone_tag): tag_names = [t['name'] for t in vehicle.get('tag_ids', [])] return zone_tag in tag_names # Voertuigen zonder LEZ-Antwerpen tag filteren lez_ok = [v for v in vehicles if can_enter_lez(v, 'LEZ-Antwerpen')]
Maak in Odoo twee voertuig-tags aan: LEZ-Antwerpen en Diesel-Euro6. Ken ze toe aan Jonas zijn bestelwagen. Lees ze via XML-RPC uit en schrijf een check die teruggeeft of een voertuig een specifieke zone mag binnenrijden.
👁 Toon oplossing
search_read('fleet.vehicle', [['driver_id.name','=','Jonas Vermeersch']], ['tag_ids'])
→ tag_ids bevat de IDs
4. Tag namen ophalen:
search_read('fleet.vehicle.tag', [['id','in', tag_ids]], ['name'])
5. Python check:
has_lez = any(t['name'] == 'LEZ-Antwerpen' for t in tags)Odoo security bestaat uit twee lagen. ir.model.access regelt CRUD rechten per model per groep (mag een technieker überhaupt stock.quant zien?). Record rules (ir.rule) filteren welke records binnen een model zichtbaar zijn (technieker ziet enkel de quants van zijn eigen bestelwagen). In DispatchIQ is dit essentieel: Jonas mag geen stock van Pieter zien, en geen order van een andere dispatcher aanpassen.
# security/ir.model.access.csv id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_stock_quant_tech,quant_tech,model_stock_quant,group_technician,1,0,0,0 access_stock_quant_disp,quant_disp,model_stock_quant,group_dispatcher,1,1,1,0 # Record rule in XML: technieker ziet enkel eigen voertuigstock # <record id="rule_quant_own_vehicle" model="ir.rule"> # <field name="domain_force"> # [('location_id.vehicle_id.driver_id.user_id','=',user.id)] # </field> # </record> # Python: test als een andere gebruiker (sudo voor admin check) env_jonas = self.env['stock.quant'].with_user(jonas_user) quants_jonas = env_jonas.search([]) # geeft enkel Jonas zijn quants
Log in als technieker Jonas (maak een testgebruiker aan). Ga naar Voorraad → Producten → Quants. Wat zie je? Vergelijk met wat je als admin ziet. Kloppen de rechten met wat je verwacht?
👁 Toon oplossing
Elke stock.picking en fsm.order erft van mail.thread. Dat geeft automatisch een chatter onderaan elk record. Wanneer je op een veld tracking='onchange' of tracking=True zet, logt Odoo automatisch oude en nieuwe waarde bij elke schrijfoperatie. In DispatchIQ gebruik je dit om te reconstrueren: wie heeft wanneer welke order verplaatst, welk onderdeel is wanneer gebruikt, en wie heeft de status gewijzigd.
# mail.message uitlezen voor een fsm.order messages = models.execute_kw(db, uid, password, 'mail.message', 'search_read', [[ ['res_id', '=', order_id], ['model', '=', 'fsm.order'], ['message_type', 'in', ['notification', 'comment']] ]], {'fields': ['date', 'author_id', 'body', 'tracking_value_ids'], 'order': 'date asc'} ) for msg in messages: print(msg['date'], msg['author_id'][1], msg['body'][:60])
Wijzig de status van een fsm.order van draft naar confirmed. Open daarna het chatter-vak. Wat staat er gelogd? Lees hetzelfde via XML-RPC uit met mail.message.
👁 Toon oplossing
search_read('mail.message', [['res_id','=',order_id],['model','=','fsm.order']], ['date','body','tracking_value_ids'])
4. tracking_value_ids bevat IDs van mail.tracking.value records
5. Lees die op: search_read('mail.tracking.value', [['id','in', tracking_ids]], ['field','old_value_char','new_value_char'])Cycle counting is het systematisch hertellen van een subset van je voorraad op regelmatige basis — efficiënter dan een jaarlijkse volledige inventaris. In Odoo gebruik je stock.inventory (v16) of de Counting actie op stock.quant (v17). Voor DispatchIQ plan je wekelijkse tellingen per bestelwagen: Jonas telt zijn koelonderdelen elke vrijdag. Afwijkingen worden onmiddellijk gecorrigeerd en gelogd zodat de constraints engine altijd actuele data heeft.
# Odoo 17: cycle count via stock.quant action_count quant_ids = models.execute_kw(db, uid, password, 'stock.quant', 'search', [['location_id', '=', vehicle_loc_id]] ) # Stel getelde hoeveelheid in voor elk quant for qid in quant_ids: models.execute_kw(db, uid, password, 'stock.quant', 'write', [[qid], {'inventory_quantity': getelde_hoeveelheid[qid]}] ) # Valideer de telling — dit maakt correctie-moves aan models.execute_kw(db, uid, password, 'stock.quant', 'action_apply_inventory', [quant_ids] )
Ga naar Voorraad → Operaties → Fysieke Inventarisatie. Filter op Jonas zijn locatie. Verander één product op 3 (terwijl er 5 zouden moeten zijn). Valideer. Wat verschijnt er in de chatter van dat quant-record?
👁 Toon oplossing
Dead stock zijn onderdelen die al maanden op een bestelwagen liggen zonder ooit gebruikt te worden. Ze blokkeren kapitaal en nemen ruimte in die nuttigere onderdelen zou kunnen bevatten. In Odoo zoek je dit op via stock.move.line: wanneer is het product voor het laatste uit een voertuiglocatie vertrokken? Producten zonder recente uitgaande beweging zijn kandidaten voor terugplaatsing in het centraal magazijn.
# Dead stock: producten op voertuig zonder beweging > 60 dagen from datetime import datetime, timedelta cutoff = (datetime.now() - timedelta(days=60)).strftime('%Y-%m-%d') # Alle move lines van Jonas zijn voertuig in de afgelopen 60 dagen recent_moves = models.execute_kw(db, uid, password, 'stock.move.line', 'search_read', [[ ['location_id', '=', vehicle_loc_id], ['date', '>=', cutoff], ['state', '=', 'done'] ]], {'fields': ['product_id']} ) recent_product_ids = {m['product_id'][0] for m in recent_moves} # Quants waarvan het product NIET in recent bewogen is all_quants = models.execute_kw(db, uid, password, 'stock.quant', 'search_read', [['location_id', '=', vehicle_loc_id]], {'fields': ['product_id', 'quantity']} ) dead_stock = [q for q in all_quants if q['product_id'][0] not in recent_product_ids]
Voer het dead stock script uit op Jonas zijn voertuiglocatie met een drempel van 30 dagen. Welke producten zijn kandidaten? Wat doe je met die informatie in een echte DispatchIQ deployment?
👁 Toon oplossing
GET /api/fleet/:vehicleId/dead-stock die dit rapport retourneert zodat de dispatcher het in de UI kan zien.// Min/max alert query op voertuiglocatie const lowStock = await odoo.searchRead( 'stock.quant', [['location_id', '=', vehicleLocationId], ['quantity', '<', 2]], ['product_id', 'quantity'] ); if (lowStock.length) { notifyDispatcher('Aanvulling nodig', lowStock); }
Bouw voertuigstock met min/max regels per bestelwagen en toon welke onderdelen automatisch moeten worden aangevuld voor de volgende dag.
Een werkende planning zonder security is onbruikbaar. In deze week zet je toegangsrechten en auditsporen correct op zodat dispatchers, techniekers en administratie elk alleen zien wat ze moeten zien.
Je werkt met least-privilege als basisprincipe: rechten zijn expliciet, controleerbaar en testbaar. Elke uitzondering wordt gemotiveerd zodat audits later herleidbaar blijven tot bewuste keuzes.
security/ir.model.access.csv is het enige bestand dat Odoo leest om te bepalen welke groep mag lezen, schrijven, aanmaken of verwijderen op een model. Elke rij heeft een uniek id, een leesbare naam, het model (via external id), de groep, en vier binaire vlaggen: perm_read, perm_write, perm_create, perm_unlink. Als dit bestand ontbreekt krijgt enkel de Administrator toegang. In DispatchIQ heeft de dispatcher schrijfrechten op fsm.order, de technieker enkel lees+schrijf (geen create/delete), en facturatie heeft alleen leestoegang op afgewerkte orders.
# security/ir.model.access.csv id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fsm_order_disp,fsm_order_disp,fieldservice.model_fsm_order,group_dispatcher,1,1,1,0 access_fsm_order_tech,fsm_order_tech,fieldservice.model_fsm_order,group_technician,1,1,0,0 access_fsm_order_inv,fsm_order_inv,fieldservice.model_fsm_order,group_invoicing,1,0,0,0 access_horeca_equip_disp,horeca_equip_disp,model_horeca_equipment,group_dispatcher,1,1,1,1 access_horeca_equip_tech,horeca_equip_tech,model_horeca_equipment,group_technician,1,0,0,0
Maak een groep group_technician aan in je module. Voeg in de access CSV leesrechten toe op fsm.location voor techniekers. Installeer de module en verifieer via Instellingen → Technisch → Beveiliging → Toegangsrechten.
👁 Toon oplossing
Record rules (ir.rule) zijn een tweede beveiligingslaag bovenop model access: ze filteren welke rijen binnen een model een gebruiker mag zien of aanpassen. Een domain filter als [('person_id.user_id', '=', user.id)] zorgt dat technieker Jonas enkel zijn eigen orders te zien krijgt. Record rules werken cumulatief binnen een groep (OR) maar multiplicatief tussen groepen (AND). Vergeet nooit een global=False regel te testen als admin — admins zijn vrijgesteld van record rules tenzij je global=True instelt.
<!-- security/record_rules.xml --> <record id="rule_fsm_order_technician" model="ir.rule"> <field name="name">FSM Order: technieker enkel eigen orders</field> <field name="model_id" ref="fieldservice.model_fsm_order"/> <field name="groups" eval="[(4, ref('dispatch_iq_horeca.group_technician'))]"/> <field name="domain_force"> [('person_id.user_id', '=', user.id)] </field> <field name="perm_read" eval="True"/> <field name="perm_write" eval="True"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> </record>
Maak 2 testorders aan: één met person_id = Jonas, één met person_id = Pieter. Log in als Jonas. Ga naar Field Service → Orders. Hoeveel orders ziet Jonas? Verwijder de record rule tijdelijk en herlaad — wat verandert er?
👁 Toon oplossing
Door mail.thread en mail.activity.mixin te erven in je model, krijg je automatisch een chatter onderaan elk record. Velden met tracking=True worden automatisch gelogd bij elke wijziging: Odoo slaat de oude en nieuwe waarde op in mail.tracking.value records. In DispatchIQ is dit de primaire auditbron: wie heeft de status van order FSM/2026/0042 gewijzigd van "In uitvoering" naar "Afgesloten", op welk tijdstip, en via welk kanaal (UI, API, wizard)?
# Model met volledige tracking class HorecaEquipment(models.Model): _name = 'horeca.equipment' _inherit = ['mail.thread', 'mail.activity.mixin'] name = fields.Char(tracking=True) state = fields.Selection([ ('active', 'Actief'), ('defect', 'Defect'), ('retired', 'Uitgedienst'), ], default='active', tracking=True) technician_id = fields.Many2one('fsm.person', tracking=True) # Handmatige log entry (in Python logic) equipment.message_post( body='Apparaat verplaatst naar ander voertuig wegens noodinterventie.', message_type='comment', subtype_xmlid='mail.mt_note' )
Voeg tracking=True toe aan het veld state van horeca.equipment. Wijzig de status van een apparaat. Lees daarna via XML-RPC de mail.tracking.value records op voor dat equipment-record. Wat staat er in old_value_char en new_value_char?
👁 Toon oplossing
mail.activity zijn tijdgebonden taken die je koppelt aan een record: "Bel klant terug", "Controleer onderdelen", "Goedkeuring manager". Ze hebben een deadline, een verantwoordelijke gebruiker en een activiteitstype. In DispatchIQ gebruik je activities om dispatchers te herinneren aan openstaande vaststellingen en om techniekers te begeleiden bij het afsluiten van complexe interventies. Activities die verlopen zijn worden rood getoond in de Odoo UI.
# Activity aanmaken op een fsm.order order.activity_schedule( 'mail.mail_activity_data_todo', # activiteitstype date_deadline=fields.Date.today() + timedelta(days=2), summary='Onderdelen bestellen voor COND-R32', note='Leverancier bevestigde levering binnen 48u. Herbevestig datum bij klant.', user_id=dispatcher_user.id ) # Alle vervallen activities voor DispatchIQ opvragen overdue = env['mail.activity'].search([ ['res_model', '=', 'fsm.order'], ['date_deadline', '<', fields.Date.today()], ['user_id', '=', uid] ])
Maak een activity aan op een fsm.order met deadline over 1 dag. Log in als de verantwoordelijke gebruiker. Zoek de activity op via Chatter én via XML-RPC. Markeer de activity als "Gedaan" via de UI. Verschijnt er een log in de chatter?
👁 Toon oplossing
Security is pas bewezen als je het test vanuit de perspectief van echte gebruikers. De gouden regel: test altijd met minimaal drie rollen: admin (geen beperkingen), dispatcher (beperkt tot eigen data), en technieker (enkel eigen orders en eigen voertuig). Odoo heeft een handige "debug modus als gebruiker" waarmee je snel van rol wisselt. In Python tests gebruik je self.env['fsm.order'].with_user(tech_user) om rechtstreeks als een andere gebruiker te zoeken.
# tests/test_access_roles.py class TestAccessRoles(TransactionCase): def setUp(self): super().setUp() self.dispatcher = self.env['res.users'].create({ 'name': 'Dispatcher Test', 'login': 'disp_test', 'groups_id': [(4, self.env.ref('dispatch_iq_horeca.group_dispatcher').id)] }) self.technician = self.env['res.users'].create({ 'name': 'Jonas Test', 'login': 'jonas_test', 'groups_id': [(4, self.env.ref('dispatch_iq_horeca.group_technician').id)] }) def test_technician_cannot_create_order(self): """Technieker mag geen orders aanmaken.""" with self.assertRaises(AccessError): self.env['fsm.order'].with_user(self.technician).create( {'name': 'FSM/HACK/1'} )
Log manueel in als technieker Jonas. Probeer een nieuwe fsm.order aan te maken via de UI. Wat verschijnt er? Probeer daarna een order van Pieter te openen via zijn directe URL. Wat ziet Jonas?
👁 Toon oplossing
Bepaalde velden bevatten gevoelige data: contractprijzen, klanttelefoonnummers, koelmiddel-certificaten of verzekeringsnummers. In Odoo schermt je deze af via field-level security: een computed veld met groups='...' attribuut is onzichtbaar voor gebruikers buiten de groep. Voor exportbeperking zet je de CRUD rechten op de bijbehorende ir.model.access rij, of gebruik je een server action die exportpogingen logt en blokkeert.
# Veld enkel zichtbaar voor administratie contract_price = fields.Float( 'Contractprijs', groups='dispatch_iq_horeca.group_admin_invoicing' ) # XML view: veld verbergen via attrs (UI-niveau, niet security) # <field name="contract_price" # groups="dispatch_iq_horeca.group_admin_invoicing"/> # ORM test: technieker kan veld niet lezen equip_as_tech = self.env['horeca.equipment'].with_user(tech_user) # contract_price geeft False (niet 0.0) als het veld afgeschermd is assert equip_as_tech.browse(equip_id).contract_price == False
Voeg een veld maintenance_price toe aan horeca.equipment met groups='base.group_system'. Lees het veld uit als technieker Jonas via XML-RPC. Wat retourneert Odoo? Hoe weet je als developer dat het veld afgeschermd is en niet gewoon leeg?
👁 Toon oplossing
Voor interne controle en GDPR-compliance wil je periodiek kunnen exporteren: welke gebruiker heeft welke records gelezen, aangemaakt of gewijzigd? Odoo heeft geen ingebouwde full audit log, maar via mail.message + mail.tracking.value reconstrueer je alle bewuste wijzigingen aan getrackte velden. Aanvullend kan je een ir.rule combineren met een scheduled action die statuswijzigingen op gevoelige models naar een externe auditdatabase schrijft via REST.
# Audit export: alle fsm.order wijzigingen van afgelopen maand from datetime import datetime, timedelta cutoff = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S') messages = models.execute_kw(db, uid, pw, 'mail.message', 'search_read', [[ ['model', '=', 'fsm.order'], ['date', '>=', cutoff], ['tracking_value_ids', '!=', False] ]], {'fields': ['date', 'author_id', 'res_id', 'tracking_value_ids']} ) # Schrijf als CSV naar audit bestand import csv with open('audit_export.csv', 'w') as f: writer = csv.DictWriter(f, fieldnames=['date', 'author', 'order_id', 'changes']) writer.writeheader() for m in messages: writer.writerow({'date': m['date'], 'author': m['author_id'][1], 'order_id': m['res_id'], 'changes': m['tracking_value_ids']})
Schrijf een script dat alle wijzigingen op fsm.order.stage_id van de afgelopen 7 dagen exporteert als CSV: datum, gebruiker, order-ID, oude stage, nieuwe stage. Hoeveel rijen bevat je export?
👁 Toon oplossing
Wanneer een beveiligingsincident optreedt — een technieker die orders van collega's aanpast, of een API-token dat data exfiltreert — heb je een reproduceerbaar stappenplan nodig. In Odoo blokkeer je een gebruiker via res.users.write({'active': False}). Forensisch onderzoek verloopt via mail.message + ir.logging (server-side fouten) + je eigen API proxy logs. DispatchIQ stuurt alle constraint-calls door een centrale logger met trace-ID zodat elke actie terugvindbaar is.
# Incident response: gebruiker onmiddellijk blokkeren suspicious_user_id = 42 models.execute_kw(db, admin_uid, admin_pw, 'res.users', 'write', [[suspicious_user_id], {'active': False}] ) # Forensiek: alle acties van die gebruiker reconstructen messages = models.execute_kw(db, admin_uid, admin_pw, 'mail.message', 'search_read', [['author_id.user_ids', '=', suspicious_user_id]], {'fields': ['date', 'model', 'res_id', 'body'], 'order': 'date desc', 'limit': 200} ) for m in messages: print(m['date'], m['model'], '#' + str(m['res_id']))
Simuleer een incident: log in als Jonas, open 5 orders van anderen (die hij niet mag zien). Log daarna als admin in en reconstrueer via mail.message welke records Jonas heeft proberen te benaderen. Schrijf daarna een script dat Jonas blokkeert.
👁 Toon oplossing
# security/ir.model.access.csv id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fsm_order_dispatcher,access_fsm_order_dispatcher,model_fsm_order,group_dispatcher,1,1,1,0 access_fsm_order_technician,access_fsm_order_technician,model_fsm_order,group_technician,1,1,0,0 # record rule: technieker ziet enkel eigen orders [('person_id.user_id', '=', user.id)]
TechniCool NV in Odoo heeft: 3 technici met eigen voertuigstock, 5 klanten met locaties en apparatuur, en correcte rolgebaseerde toegang. Exporteer een auditrapport met wie welke orderstatus wijzigde.
OCA &
Configuratie
Odoo Community Association modules installeren, begrijpen en configureren. Van fieldservice_stock tot fieldservice_agreement. De brug naar addon development.
Dit hoofdstuk maakt je productieklaar met het OCA-ecosysteem. Je leert hoe je modules correct importeert, dependency-ketens leest en versieconflicten voorkomt, zodat upgrades beheersbaar blijven in een commerciële context.
Je behandelt OCA als volwassen supply chain: je kiest branches bewust, pinnt versies en houdt changelogs bij. Zo kan je updates plannen zonder onverwachte regressies in planning of facturatie.
De Odoo Community Association host meer dan 60 GitHub repositories onder github.com/OCA. Elke repository groepeert modules per domein: field-service, stock-logistics-warehouse, fleet, account-financial-tools. Binnen elke repo heb je branches per Odoo versie: 16.0, 17.0, 18.0. Een module heeft altijd een intern versienummer in het manifest: 17.0.1.2.0 betekent Odoo 17, major 1, minor 2, patch 0. OCA volgt het semantic versioning principe maar de eerste component is altijd de Odoo-versie.
# OCA GitHub structuur begrijpen # Repository: github.com/OCA/field-service # Branch: 17.0 # Modules in die repo (selectie): # fieldservice/ — basis field service (fsm.order, fsm.location, fsm.person) # fieldservice_stock/ — koppeling met inventory (parts flow) # fieldservice_account/ — koppeling met invoicing # fieldservice_agreement/— onderhoudscontracten # fieldservice_fleet/ — voertuigkoppeling # Zoeken in OCA voor een specifieke module: # 1. github.com/OCA/field-service/tree/17.0 # 2. Bekijk README.md van elke module voor beschrijving en status badge # 3. Status: Production/Beta/Alpha
Ga naar github.com/OCA/field-service. Zoek de module fieldservice_stock. Hoeveel open issues heeft die module? Welke Odoo-versie branches bestaan er? Wat staat er in de README als installatiestatus?
👁 Toon oplossing
De OCA field-service repo bevat een cluster van modules die samenwerken. Je moet hun onderlinge afhankelijkheden begrijpen voordat je iets installeert — een missing dependency crasht de installatie zonder duidelijke foutmelding. De basis-hiërarchie voor DispatchIQ is: fieldservice (basis) → fieldservice_stock (parts) → fieldservice_account (facturatie) → fieldservice_agreement (contracten). Elke module in de ketting moet aanwezig zijn in je addons_path.
# __manifest__.py van fieldservice_stock lezen # Vind je op: OCA/field-service/fieldservice_stock/__manifest__.py { 'name': 'Field Service - Stock', 'version': '17.0.1.0.0', 'depends': [ 'fieldservice', # OCA fieldservice basis 'stock', # Odoo core inventory 'stock_picking_type_charge', # OCA stock module ], 'external_dependencies': { 'python': [], # geen extra pip packages nodig }, 'data': [ 'security/ir.model.access.csv', 'views/fsm_order_views.xml', 'views/stock_picking_views.xml', ] }
Open de __manifest__.py van fieldservice_agreement. Teken de volledige dependency tree op papier. Welke Odoo core modules zijn vereist? Welke OCA modules heb je bijkomend nodig naast fieldservice?
👁 Toon oplossing
OCA modules installeer je door de GitHub repository te clonen naar een map die je toevoegt aan addons_path in odoo.conf. In een Docker-omgeving (zoals je Hetzner setup) mount je die map als volume en pas je de entrypoint aan. Na de clone en voor de eerste start update je de module lijst met odoo -u base --stop-after-init. Daarna verschijnt de OCA module in het Apps menu. Installeer altijd eerst op een development-database, nooit rechtstreeks op productie.
# docker-compose.yml extra-addons volume services: odoo: image: odoo:17.0 volumes: - ./extra-addons:/mnt/extra-addons environment: - HOST=db command: odoo --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons # Shell: OCA modules clonen naar extra-addons cd /mnt/extra-addons git clone -b 17.0 --depth=1 https://github.com/OCA/field-service.git OCA-field-service git clone -b 17.0 --depth=1 https://github.com/OCA/fleet.git OCA-fleet # Symlinks zodat Odoo de individuele modules vindt for mod in OCA-field-service/fieldservice OCA-field-service/fieldservice_stock; do ln -sf /mnt/extra-addons/$mod /mnt/extra-addons/$(basename $mod) done
Clone de OCA field-service repo (branch 17.0) naar je server. Voeg de addons-path toe in je docker-compose. Herstart Odoo. Ga naar Apps → Zoek "Field Service Stock". Verschijnt de OCA module? Installeer haar.
👁 Toon oplossing
De __manifest__.py is het paspoort van een Odoo module. Odoo leest dit bestand bij elke start om dependencies te resolven, bestanden te laden en de installatiestatus bij te houden. De depends lijst bepaalt de laadvolgorde: alle dependencies moeten al geladen zijn voordat jouw module start. external_dependencies zijn Python pip-packages of binaire tools (zoals wkhtmltopdf) die buiten Odoo moeten worden geïnstalleerd. Mis je een externe dependency, dan faalt de import stil.
# Volledige __manifest__.py voor dispatch_iq_horeca { 'name': 'DispatchIQ Horeca', 'version': '17.0.1.0.0', 'summary': 'Horeca field service extensies voor DispatchIQ', 'description': """Voegt horeca.equipment model, werkbon PDF en skills constraint check toe aan de OCA fieldservice module.""", 'author': 'TechniCool NV', 'website': 'https://dispatchiq.be', 'license': 'LGPL-3', 'category': 'Field Service', 'depends': ['fieldservice', 'fieldservice_stock', 'mail', 'fleet'], 'external_dependencies': {'python': ['vobject']}, # pip install vobject 'data': [ 'security/groups.xml', 'security/ir.model.access.csv', 'views/horeca_equipment_views.xml', 'report/werkbon_template.xml', ], 'demo': ['demo/demo_data.xml'], 'installable': True, 'auto_install': False, }
Open de __manifest__.py van 3 OCA modules naar keuze. Welke velden zijn verplicht? Wat is het verschil tussen auto_install: True en False? Wanneer zou je auto_install op True zetten?
👁 Toon oplossing
Een OCA module volgt altijd dezelfde mappenstructuur. models/ bevat de Python ORM klassen. views/ bevat de XML forms, lists en kanbans. security/ bevat de access CSV en record rules. data/ bevat initiële data (stages, e-mail templates). static/ bevat JavaScript en CSS. tests/ bevat unit tests. Door deze structuur te kennen, kan je in 30 seconden vinden welk model verantwoordelijk is voor een bepaald gedrag, of welke view je moet extenden.
# Structuur van fieldservice module (vereenvoudigd) fieldservice/ ├── __init__.py ├── __manifest__.py ├── models/ │ ├── __init__.py │ ├── fsm_order.py # het hoofdmodel │ ├── fsm_location.py # klantlocaties │ ├── fsm_person.py # techniekers │ └── fsm_stage.py # order stages ├── views/ │ ├── fsm_order_views.xml # form, list, kanban van orders │ └── menus.xml # navigatiemenu structuur ├── security/ │ ├── groups.xml │ └── ir.model.access.csv ├── data/ │ └── fsm_stage_data.xml # standaard stages └── tests/ └── test_fsm_order.py
Open fieldservice/models/fsm_order.py op GitHub. Zoek het veld person_id. Welk type is het? Heeft het een tracking attribuut? Zoek daarna in views/fsm_order_views.xml hoe dit veld in de form view staat. Is het readonly in bepaalde states?
👁 Toon oplossing
OCA modules zijn strikt versiegebonden. Een module op branch 16.0 werkt NIET op Odoo 17 — het ORM, de view syntax en sommige model namen zijn gewijzigd. Elke OCA branch heeft zijn eigen set actieve modules: niet elke module die bestaat op 16.0 bestaat ook op 17.0. Check altijd eerst of de branch bestaat en of de module al geport is. De OCA runbot (runbot.odoo-community.org) toont welke modules passing tests hebben per versie.
# Controleer welke branch je nodig hebt # Regel: branch = je Odoo versie, altijd # Goed: 17.0 Odoo met 17.0 OCA modules git clone -b 17.0 https://github.com/OCA/field-service.git # FOUT: 17.0 Odoo met 16.0 OCA modules # git clone -b 16.0 https://github.com/OCA/field-service.git # → ImportError, XML parse errors, missing columns # Controleer versie in manifest na clone: import ast with open('fieldservice/__manifest__.py') as f: manifest = ast.literal_eval(f.read()) print(manifest['version']) # '17.0.x.x.x' — eerste deel = Odoo versie
Ga naar de OCA field-service repo. Vergelijk de branches 16.0 en 17.0. Bestaan er modules in 16.0 die nog niet geport zijn naar 17.0? Zoek de OCA runbot en kijk welke fieldservice modules daar groen staan voor 17.0.
👁 Toon oplossing
OCA repositories worden dagelijks geüpdated. Als je altijd de laatste commit trekt (git pull), riskeer je onverwachte regressies in productie. De oplossing is pinning: je legt exact vast welke commit of release tag je gebruikt. In een professionele setup bewaar je dit in een requirements.txt of .gitmodules bestand. Zo kan elke developer en elke deployment server exact dezelfde versie reproduceren.
# Optie 1: pin op een specifieke commit hash git clone https://github.com/OCA/field-service.git OCA-field-service cd OCA-field-service git checkout a3f7c2d # specifieke commit — voor altijd reproduceerbaar # Optie 2: pin op een release tag (als die beschikbaar is) git checkout tags/17.0.1.0.0 # Optie 3: git submodule in je project repo # .gitmodules: # [submodule "OCA-field-service"] # path = extra-addons/OCA-field-service # url = https://github.com/OCA/field-service.git # branch = 17.0 # Bewaar de gebruikte commit in een lockfile echo "OCA-field-service: $(git -C OCA-field-service rev-parse HEAD)" >> oca_versions.lock
Maak een oca_versions.lock bestand aan dat de huidige commit hashes van al je geclonede OCA repos bijhoudt. Schrijf een klein bash-scriptje dat controleert of de lokale commits overeenkomen met het lockbestand.
👁 Toon oplossing
Voor elke OCA module-update volg je een vaste rehearsal-procedure op staging. Je trekt de nieuwe versie, vergelijkt de changelog (of git log) op breaking changes, maakt een DB dump van productie, restore op staging, en draait odoo -u fieldservice_stock. Daarna valideer je manueel de kritieke flows: order aanmaken, parts check, werkbon genereren. Pas als alles groen is, update je productie — nooit omgekeerd.
# Upgrade rehearsal stappenplan (bash) # 1. Productie DB dump naar staging docker exec -t odoo_db pg_dump -U odoo technicool_prod | \ docker exec -i staging_db psql -U odoo technicool_staging # 2. OCA module updaten op staging server cd extra-addons/OCA-field-service git fetch origin git log HEAD..origin/17.0 --oneline # bekijk wat er veranderd is git pull origin 17.0 # 3. Module updaten in Odoo staging docker exec odoo_staging odoo -d technicool_staging \ -u fieldservice_stock --stop-after-init \ --log-level=info 2>&1 | tee upgrade_log.txt # 4. Controleer op errors in logbestand grep -i "ERROR\|CRITICAL\|Traceback" upgrade_log.txt
Schrijf een checklist van 5 handmatige testpunten die je uitvoert na elke OCA fieldservice update op staging. Denk aan de kritieke flows van DispatchIQ: wat moet absoluut werken voordat je naar productie gaat?
👁 Toon oplossing
# addons-path in odoo.conf [options] addons_path = /usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons/OCA/field-service db_host = db db_port = 5432 db_user = odoo db_password = odoo # Update modulelijst na clone odoo -d technicool -u fieldservice,fieldservice_stock --stop-after-init
Installeer fieldservice (basis), fieldservice_stock, en fieldservice_account op je Hetzner Odoo instantie. Lees de __manifest__.py van elk en verklaar de dependency tree. Wat installeert fieldservice_stock extra aan het datamodel?
Dit hoofdstuk draait om domeinkennis van het model waar je planning op steunt: fsm.order. Je bekijkt niet alleen velden, maar ook hoe stageflow, templates en parts-data elkaar beïnvloeden in echte dispatchscenario’s.
Naast veldkennis oefen je impactanalyse: welke aanpassing in fsm.order breekt downstream APIs, rapporten of automatiseringen. Dat maakt je integraties robuust bij toekomstige uitbreiding.
Het fsm.order model is de kern van heel DispatchIQ. De standaard velden omvatten: name (referentienummer), partner_id (klant), location_id (fsm.location), person_id (toegewezen technieker), stage_id, scheduled_date_start + scheduled_date_end, type (vaststelling/herstel/onderhoud), template_id (checklist), en de OCA toevoeging product_ids (benodigde onderdelen). Elk veld in dit model heeft een directe impact op een feature in DispatchIQ.
# Kritieke velden van fsm.order voor DispatchIQ order = models.execute_kw(db, uid, pw, 'fsm.order', 'read', [[order_id]], {'fields': [ 'name', # FSM/2026/0042 'partner_id', # [12, 'Frituur De Gouden Aardappel'] 'location_id', # [8, 'Antwerpsesteenweg 42, Gent'] 'person_id', # [3, 'Jonas Vermeersch'] 'stage_id', # [2, 'In uitvoering'] 'scheduled_date_start', # '2026-03-15 08:00:00' 'scheduled_date_end', # '2026-03-15 10:00:00' 'type', # 'vaststelling' | 'herstel' | 'onderhoud' 'product_ids', # [42, 55] — benodigde onderdelen 'priority', # '0' normaal | '1' urgent | '2' kritisch ]} )[0]
Doe een XML-RPC call die alle velden van één fsm.order ophaalt (zonder fields-filter). Hoeveel velden heeft het model? Welke 5 velden zijn het meest relevant voor de Gantt weergave in DispatchIQ?
👁 Toon oplossing
fsm.stage definieert de stappen in een order lifecycle. Elke stage heeft een name, een sequence (volgorde), en de kritieke boolean is_closed. Enkel stages met is_closed=True eindigen de order lifecycle — dit bepaalt welke orders niet meer gewijzigd kunnen worden. Voor DispatchIQ maak je horeca-specifieke stages: Nieuw → Ingepland → Onderweg → Vaststelling → Wacht op onderdelen → Hersteld → Gefactureerd. De stage-naam verschijnt als tooltip op de Gantt-balk.
# Horeca stages aanmaken via XML data file <!-- data/fsm_stage_data.xml --> <record id="stage_new" model="fsm.stage"><field name="name">Nieuw</field> <field name="sequence">1</field></record> <record id="stage_planned" model="fsm.stage"><field name="name">Ingepland</field> <field name="sequence">2</field></record> <record id="stage_traveling" model="fsm.stage"><field name="name">Onderweg</field> <field name="sequence">3</field></record> <record id="stage_waiting" model="fsm.stage"><field name="name">Wacht op stock</field><field name="sequence">4</field></record> <record id="stage_done" model="fsm.stage"> <field name="name">Afgerond</field> <field name="sequence">10</field> <field name="is_closed" eval="True"/> <!-- order is afgesloten --> </record>
Maak de horeca-specifieke stages aan via de Odoo UI. Stel is_closed=True in op "Gefactureerd". Maak daarna een order aan en beweeg hem door alle stages. Wat verandert er in de UI zodra je een order op een closed stage zet?
👁 Toon oplossing
De OCA fieldservice_stock module voegt het veld product_ids toe aan fsm.order: een lijst van benodigde onderdelen met hoeveelheden. Bij het afsluiten van een order (stage_id.is_closed = True) maakt Odoo automatisch een stock.picking aan die de verbruikte onderdelen boekt vanuit het voertuig van de technieker. Dit is de brug tussen "wat de technieker nodig heeft" en "wat er van zijn voertuig afging".
# Benodigde onderdelen toevoegen aan een fsm.order # product_ids is een Many2many naar fsm.order.line (niet rechtstreeks product) order_line_ids = models.execute_kw(db, uid, pw, 'fsm.order.product', 'search_read', [['order_id', '=', order_id]], {'fields': ['product_id', 'product_uom_qty', 'qty_done']} ) # Geeft bv: [{'product_id': [42,'COND-R32'], 'product_uom_qty': 1.0, 'qty_done': 0.0}] # Stock picking die aangemaakt werd bij afsluiten picking = models.execute_kw(db, uid, pw, 'stock.picking', 'search_read', [['fsm_order_id', '=', order_id]], {'fields': ['name', 'state', 'move_ids']} )
Voeg het product COND-R32 toe aan een fsm.order via de UI. Sluit de order af (zet op afgesloten stage). Ga naar Voorraad → Operaties → Transfers. Welke picking werd aangemaakt? Van welke locatie naar welke?
👁 Toon oplossing
De volledige parts-flow in DispatchIQ heeft 5 stappen: 1) Vaststelling: technieker stelt vast dat onderdeel nodig is. 2) Order gaat op "Wacht op stock". 3) Dispatcher plaatst een inkooporder voor het onderdeel. 4) Onderdeel arriveert en wordt ingeboekt op de locatie. 5) Automated action detecteert stockaankomst en herstelt order naar "Ingepland". DispatchIQ beheert stap 5 via een base.automation die luistert op stockwijzigingen.
# Automated action: bij stock ontvangst herstelorder herplannen <!-- In data/automated_actions.xml --> <record id="action_replan_on_stock" model="base.automation"> <field name="model_id" ref="stock.model_stock_move"/> <field name="trigger">on_write</field> <field name="filter_pre_domain">[('state','!=','done')]</field> <field name="filter_domain">[('state','=','done')]</field> <field name="action_server_id"> <record model="ir.actions.server"> <field name="state">code</field> <field name="code"> for move in records: orders = env['fsm.order'].search([ ('stage_id.name','=','Wacht op stock'), ('product_ids.product_id','=',move.product_id.id) ]) planned = env.ref('dispatch_iq_horeca.stage_planned') orders.write({'stage_id': planned.id})</field> </record> </field> </record>
Teken de volledige parts-flow op papier als sequentie-diagram: actor (dispatcher/technieker/systeem), actie, modelwijziging. Welke stap is het riskantst als hij faalt? Wat is het fallback gedrag?
👁 Toon oplossing
fsm.template definieert herbruikbare checklists per type interventie. Je koppelt een template aan een fsm.order via template_id. De template bevat een lijst van checkpunten (fsm.checklist) die de technieker één voor één afvinkt op zijn mobiel. Voor DispatchIQ maak je per categorie een template: "Combi-steamer preventief onderhoud", "Blast chiller koelmiddelcheck", "Vaatwasser jaarlijks". Elk checklist-item heeft een naam en optioneel een vereist product of meetwaarde.
# Template aanmaken via XML-RPC template_id = models.execute_kw(db, uid, pw, 'fsm.template', 'create', [{ 'name': 'Combi-steamer preventief onderhoud', 'checklist_ids': [ (0, 0, {'name': 'Stoomgenerator ontkalken'}), (0, 0, {'name': 'Dichtingen controleren'}), (0, 0, {'name': 'Temperatuurkalibratie uitvoeren'}), (0, 0, {'name': 'Reinigingsprogramma doorlopen'}), ] }] ) # Koppelen aan een order models.execute_kw(db, uid, pw, 'fsm.order', 'write', [[order_id], {'template_id': template_id}] )
Maak een template "Koelinstallatie R32 jaarlijkse keuring" met 6 checkpunten (druk, koelmiddelniveau, LEK-test, elektrische verbindingen, isolatie, reinigen condensor). Koppel het aan een test-order. Open de order als technieker en vink de punten af.
👁 Toon oplossing
De parts check is één van de kritieke constraints van DispatchIQ. De API proxy endpoint GET /api/constraints/parts?orderId=X&techId=Y voert twee opeenvolgende Odoo calls uit: eerst haal je de benodigde producten op van fsm.order.product, dan check je per product of het in voldoende hoeveelheid aanwezig is op het voertuig van de technieker via stock.quant. Het resultaat is een JSON met ok: true/false en een lijst van ontbrekende items.
// Node.js: volledige parts check implementatie async function partsCheck(orderId, techId) { // Stap 1: benodigde onderdelen van de order const lines = await odoo.searchRead('fsm.order.product', [['order_id', '=', orderId]], ['product_id', 'product_uom_qty'] ); // Stap 2: voertuiglocatie van de technieker const [vehicle] = await odoo.searchRead('fleet.vehicle', [['driver_id.user_id', '=', techId]], ['stock_location_id'] ); const locId = vehicle.stock_location_id[0]; // Stap 3: check stock per product const missing = []; for (const line of lines) { const [quant] = await odoo.searchRead('stock.quant', [['product_id', '=', line.product_id[0]],['location_id','=',locId]], ['quantity'] ); const available = quant ? quant.quantity : 0; if (available < line.product_uom_qty) missing.push({ product: line.product_id[1], needed: line.product_uom_qty, available }); } return { ok: missing.length === 0, missing }; }
Implementeer het endpoint GET /api/constraints/parts in je Node.js API proxy. Test het met een order waarbij de technieker WEL de onderdelen heeft, en eén waarbij hij ze NIET heeft. Klopt de response in beide gevallen?
👁 Toon oplossing
Een fsm.order mag niet zomaar gesloten worden. In DispatchIQ hanteren we drie regels: 1) alle checklist items zijn afgevinkt, 2) de technieker heeft zijn handtekening ingescand, 3) de werkelijke duur is ingevuld. Je implementeert dit als een @api.constrains op de stage transitie, of als een base.automation die controleert voor de order op een closed stage gezet wordt. Uitzonderingen (dispatcher kan forceren) laat je toe via een force_close boolean met groepsbeperking.
# Lifecycle constraint: order afsluiten @api.constrains('stage_id') def _check_close_conditions(self): for order in self: if not order.stage_id.is_closed: continue if order.force_close: # dispatcher override continue # Checklist volledig? open_items = order.checklist_ids.filtered(lambda c: not c.is_done) if open_items: raise ValidationError( f'Sluit eerst alle checklist items af: {", ".join(open_items.mapped("name"))}' ) # Handtekening aanwezig? if not order.signature: raise ValidationError('Klanthandtekening verplicht voor afsluiting.')
Implementeer de constraint die verhindert dat een order afgesloten wordt als de checklist niet volledig is. Test: probeer een order af te sluiten met open checklist items. Wat ziet de dispatcher vs. de technieker? Voeg ook force_close toe die enkel dispatchers mogen aanvinken.
👁 Toon oplossing
Een datacontract is een expliciete afspraak over welke velden de API altijd teruggeeft, in welk formaat, en wat de gedrag is bij ontbrekende waarden. Voor DispatchIQ definieer je dit in de API proxy: het endpoint /api/orders/:id geeft altijd exact dezelfde structuur terug, ongeacht of Odoo extra velden heeft of niet. Ontbrekende optionele velden krijgen een null of lege array — nooit undefined. Dit voorkomt dat frontend-code breekt bij een Odoo-update.
// API proxy: datacontract voor fsm.order function mapOrder(raw) { return { id: raw.id, reference: raw.name || '', client: raw.partner_id ? raw.partner_id[1] : null, clientId: raw.partner_id ? raw.partner_id[0] : null, technicianId: raw.person_id ? raw.person_id[0] : null, technician: raw.person_id ? raw.person_id[1] : null, start: raw.scheduled_date_start || null, end: raw.scheduled_date_end || null, stage: raw.stage_id ? raw.stage_id[1] : null, isClosed: raw.stage_id?.is_closed || false, priority: raw.priority || '0', products: (raw.product_ids || []).map(p => p[0]), }; }
Schrijf de mapOrder functie in je API proxy en voeg een unit test toe die verifieert dat mapOrder nooit undefined retourneert, ook als Odoo een leeg of gedeeltelijk record teruggeeft.
👁 Toon oplossing
# Parts check via Odoo API # Heeft technieker Jonas condensator R32 op zijn bestelwagen? domain = [ ['product_id.default_code', '=', 'COND-R32'], ['location_id', '=', jonas_vehicle_location_id], ['quantity', '>', 0] ] quants = odoo.search_read('stock.quant', domain, ['quantity', 'product_id']) has_part = len(quants) > 0
Bouw de parts check endpoint in je API proxy: GET /api/constraints/parts?orderId=X&techId=Y. Haalt de benodigde onderdelen op van de fsm.order, controleert stock.quant per voertuig van de technieker, geeft terug: { ok: bool, missing: [{product, qty_needed, qty_available}] }. Koppel dit aan de Gantt drag waarschuwing.
In deze week koppel je werkorders direct aan voorraadstromen. DispatchIQ moet weten welke onderdelen in het voertuig zitten en automatisch signaleren wat ontbreekt voordat een technieker wordt ingepland.
Je verdiept de flow tot op transactieniveau: reservatie, verbruik, correctie en retour worden traceerbaar. Daardoor blijft stockdata betrouwbaar, ook bij no-shows en last-minute herplanning.
De OCA fieldservice_stock module introduceert het model fsm.order.product (of fsm.order.line afhankelijk van versie): één rij per benodigd product met product_id, product_uom_qty (gevraagd) en qty_done (effectief verbruikt). Dispatchers voegen producten toe bij het plannen van een herstelorder. Techniekers vullen qty_done in na uitvoering. Het verschil tussen de twee triggert eventueel een retour naar magazijn.
# Onderdelen toevoegen aan een order via XML-RPC models.execute_kw(db, uid, pw, 'fsm.order', 'write', [[order_id], { 'product_ids': [ (0, 0, {'product_id': cond_r32_id, 'product_uom_qty': 1}), (0, 0, {'product_id': fuse_10a_id, 'product_uom_qty': 3}), ] }] ) # Lees de orderlijnen terug lines = models.execute_kw(db, uid, pw, 'fsm.order.product', 'search_read', [['order_id', '=', order_id]], {'fields': ['product_id', 'product_uom_qty', 'qty_done']} ) # [{'product_id': [42,'COND-R32'], 'product_uom_qty': 1.0, 'qty_done': 0.0}, ...]
Maak een herstelorder aan voor "Frituur De Gouden Aardappel". Voeg 2 producten toe via XML-RPC. Lees de fsm.order.product records terug. Wat is de initiële waarde van qty_done? Wanneer wordt dit ingevuld?
👁 Toon oplossing
De kern van de parts-check is het filteren van stock.quant op een specifieke voertuiglocatie. stock.quant bevat de actuele voorraad per product per locatie. Het veld location_id is het filter; quantity is de beschikbare hoeveelheid; reserved_quantity is wat al gereserveerd is door andere pickings. De effectief beschikbare hoeveelheid = quantity - reserved_quantity.
# Effectieve beschikbaarheid op voertuig van Jonas quants = models.execute_kw(db, uid, pw, 'stock.quant', 'search_read', [['location_id', '=', vehicle_loc_id]], {'fields': ['product_id', 'quantity', 'reserved_quantity']} ) # Beschikbaarheid berekenen stock_map = {} for q in quants: pid = q['product_id'][0] available = q['quantity'] - q['reserved_quantity'] stock_map[pid] = available # product_id → beschikbare qty # Check of product X beschikbaar is in qty Y def has_stock(product_id, needed_qty): return stock_map.get(product_id, 0) >= needed_qty
Haal alle stock.quant records op van Jonas zijn voertuig. Bereken de effectieve beschikbaarheid (quantity - reserved_quantity) per product. Wat is het verschil tussen quantity=5, reserved=0 en quantity=5, reserved=3? Wanneer treedt reservatie op?
👁 Toon oplossing
Wanneer de parts check een tekort ontdekt, toont DispatchIQ een constraint dialoog in de Gantt: een modal met de lijst van ontbrekende onderdelen, de beschikbare hoeveelheid op het voertuig, en twee actieknoppen: "Toch inplannen" (met waarschuwing) of "Annuleren". De frontend reageert op het ok: false signaal van de API. Het is bewust geen harde blokkering — dispatchers kennen soms context die het systeem niet weet.
// React: constraint dialoog bij Gantt drag async function handleDrop(orderId, techId, newSlot) { const check = await fetch( `/api/constraints/parts?orderId=${orderId}&techId=${techId}` ).then(r => r.json()); if (!check.ok) { const confirmed = await showConstraintDialog({ title: 'Ontbrekende onderdelen', missing: check.missing, // [{product, needed, available}] message: 'Wil je toch inplannen?', }); if (!confirmed) return; // dispatcher annuleert } // Doorgaan met toewijzing await assignOrder(orderId, techId, newSlot); }
Bouw de showConstraintDialog component in React. Toon per ontbrekend product: naam, gevraagde qty en beschikbare qty in rood. Voeg twee knoppen toe: "Toch inplannen (met risico)" en "Annuleren". Log naar de console welke keuze de dispatcher maakte.
👁 Toon oplossing
In de horeca-context volgt op een vaststelling vaak een herstelorder: technicus stelt vast dat er onderdelen nodig zijn die hij niet bij heeft, sluit de vaststelling af, en een nieuwe herstelorder wordt aangemaakt zodra de onderdelen beschikbaar zijn. DispatchIQ stroomlijnt dit: de API endpoint POST /api/orders/:id/create-repair maakt de herstelorder aan in Odoo als kind van de vaststelling, met de genoteerde onderdelen als product_ids en status "Wacht op stock".
// Node.js: herstelorder aanmaken vanuit vaststelling async function createRepairOrder(vaststellingId, missingParts) { const source = await odoo.read('fsm.order', [vaststellingId], ['partner_id', 'location_id', 'person_id', 'name']); const repairId = await odoo.create('fsm.order', { name: source.name + '/HERSTEL', partner_id: source.partner_id[0], location_id: source.location_id[0], person_id: source.person_id[0], type: 'herstel', stage_id: STAGE_WACHT_OP_STOCK, origin: vaststellingId, product_ids: missingParts.map(p => [0, 0, { product_id: p.productId, product_uom_qty: p.needed }]), }); return repairId; }
Implementeer POST /api/orders/:id/create-repair. Stuur de body mee: { missingParts: [{productId, needed}] }. Test: maak een vaststelling, call het endpoint, verifieer dat de herstelorder in Odoo aangemaakt werd met correcte stage en product_ids.
👁 Toon oplossing
Bij het bevestigen van een fsm.order reserveert fieldservice_stock automatisch de benodigde producten via een stock.picking in state confirmed. De reserved_quantity in stock.quant stijgt. Bij afsluiten van de order gaat de picking naar done en daalt de quantity. DispatchIQ moet dit synchroon houden: een order die geannuleerd wordt, moet zijn reservaties vrijgeven. Anders blijven andere orders onterecht geblokkeerd op de parts-check.
# Reservaties vrijgeven bij order-annulatie def action_cancel(self): for order in self: # Annuleer alle open pickings voor deze order pickings = self.env['stock.picking'].search([ ('fsm_order_id', '=', order.id), ('state', 'not in', ['done', 'cancel']) ]) pickings.action_cancel() # geeft reservaties vrij # Zet order op geannuleerde stage order.stage_id = self.env.ref('dispatch_iq_horeca.stage_cancelled') return True # Check reservatie status via API picking = search_read('stock.picking', [['fsm_order_id', '=', order_id], ['state', '!=', 'cancel']], ['state', 'move_ids'] )
Maak een order aan met 2 producten (qty=1 elk). Bevestig de order. Controleer de reserved_quantity in stock.quant. Annuleer daarna de order. Controleert de reserved_quantity daalt? Wat is de staat van de stock.picking na annulatie?
👁 Toon oplossing
Het parts-check endpoint heeft een stabiel, gedocumenteerd JSON contract nodig. Alle consumers (de Gantt frontend, de mobiele app, toekomstige integraties) vertrouwen erop dat de response-structuur niet verandert. Definieer het contract in een OpenAPI schema of een simpele TypeScript interface, en schrijf een test die elke API-response valideert tegen dit schema.
// TypeScript schema voor parts-check response interface PartsCheckResult { ok: boolean; orderId: number; techId: number; missing: PartsCheckItem[]; // altijd array, nooit null available: PartsCheckItem[]; } interface PartsCheckItem { product: string; // display naam productCode: string; // internal reference qtyNeeded: number; qtyAvailable: number; } // Zod validatie in de Express middleware import { z } from 'zod'; const PartsCheckSchema = z.object({ ok: z.boolean(), orderId: z.number(), techId: z.number(), missing: z.array(z.object({ product: z.string(), qtyNeeded: z.number(), qtyAvailable: z.number() })), available: z.array(z.object({ product: z.string(), qtyNeeded: z.number(), qtyAvailable: z.number() })), });
Voeg Zod-validatie toe aan je parts-check endpoint. Schrijf een test die het endpoint aanroept en de response valideert met het Zod schema. Wat gebeurt er als je een veld vergeet in de mapper?
👁 Toon oplossing
Partial availability is het scenario waarbij een technieker sommige onderdelen WEL heeft maar andere niet. De parts-check moet dit nuanceren: niet alles-of-niets. DispatchIQ toont dan: "2 van 3 onderdelen beschikbaar — 1 ontbreekt". De dispatcher kan beslissen: bestelling plaatsen voor het ontbrekende deel, order splitsen in een deelinterventie, of een andere technieker kiezen die alles bij heeft.
// Uitgebreide parts check met partial availability async function partsCheckDetailed(orderId, techId) { const [lines, stockMap] = await Promise.all([ getOrderLines(orderId), getVehicleStock(techId), ]); const missing = []; const available = []; let partial = false; for (const line of lines) { const avail = stockMap[line.product_id] ?? 0; const item = { product: line.product_name, needed: line.qty, available: avail }; if (avail >= line.qty) available.push(item); else if (avail > 0) { missing.push(item); partial = true; } else missing.push(item); } return { ok: missing.length === 0, partial, // true als deels beschikbaar missing, available, score: available.length / lines.length, // 0.0–1.0 }; }
Maak een test met een order van 3 producten: A heeft Jonas genoeg, B heeft hij deels (nodig 2, heeft 1), C heeft hij niet. Wat retourneert partsCheckDetailed? Hoe toont de Gantt dit visueel — volledig rood of oranje?
👁 Toon oplossing
Na een interventie zijn soms onderdelen meegenomen maar niet gebruikt. Deze moeten terugkeren naar het voertuig of het centraal magazijn. In Odoo maak je hiervoor een return picking aan via stock.return.picking wizard, of een manuele interne transfer. DispatchIQ biedt een endpoint POST /api/orders/:id/return-parts waarbij de technieker aangeeft welke onderdelen hij niet gebruikt heeft, waarna de terugboekingstransfer automatisch aangemaakt wordt.
# Retour picking aanmaken via Python (in module) def create_return_picking(self, unused_lines): """ unused_lines: [{product_id, qty_returned}] Maakt interne transfer aan: Klantlocatie → Voertuig """ vehicle_loc = self.person_id.vehicle_id.stock_location_id return self.env['stock.picking'].create({ 'picking_type_id': self.env.ref('stock.picking_type_internal').id, 'location_id': self.location_id.stock_location_id.id, 'location_dest_id': vehicle_loc.id, 'origin': self.name + '/RETOUR', 'move_ids': [(0, 0, { 'product_id': line['product_id'], 'product_uom_qty': line['qty_returned'], 'name': 'Retour niet-gebruikt', 'location_id': self.location_id.stock_location_id.id, 'location_dest_id': vehicle_loc.id, }) for line in unused_lines] })
Simuleer een afgeronde interventie: order met 3 COND-R32, technieker gebruikt er maar 1. Maak een retour-picking van 2 stuks. Valideer de picking. Controleer de stock.quant op de bestelwagen — stijgt die met 2?
👁 Toon oplossing
// JSON antwoord voor parts-check { "ok": false, "orderId": 842, "techId": 8, "missing": [ { "product": "COND-R32", "qty_needed": 1, "qty_available": 0 } ], "available": [ { "product": "FUSE-10A", "qty_needed": 2, "qty_available": 5 } ] }
Bouw het endpoint /api/constraints/parts dat per order en technieker een lijst ontbrekende onderdelen teruggeeft op basis van voertuigvoorraad.
Met agreements automatiseer je terugkerend onderhoud en maak je planning voorspelbaar. Hierdoor verschuif je van reactieve interventies naar proactieve service, wat perfect aansluit bij commerciële uitrol van DispatchIQ.
Je ontwerpt contracten met operationele realiteit in gedachten: seizoenspiek, toegangsmomenten en SLA's verschillen per klanttype. Goede parametrisatie voorkomt handmatige uitzonderingen in de dispatchkalender.
Een fieldservice.agreement is een onderhoudscontract dat periodiek fsm.orders genereert. Elk agreement heeft een template (checklist + standaard onderdelen), een klant (partner_id), een locatie, en een recurrenceregel. Voor DispatchIQ maak je per apparaatcategorie een template: "Combi-steamer jaarlijks", "Koelinstallatie halfjaarlijks", "Vaatwasser kwartaal". De template bepaalt ook de standaard duurtijd en benodigde technieker-skills.
# Agreement aanmaken via XML-RPC agreement_id = models.execute_kw(db, uid, pw, 'fieldservice.agreement', 'create', [{ 'name': 'Hotel Metropool — Combi-steamers jaarcontract', 'partner_id': hotel_partner_id, 'fsm_location_id': hotel_location_id, 'template_id': combi_steamer_template_id, 'recurrence_type': 'yearly', 'start_date': '2026-01-01', 'end_date': '2028-12-31', 'next_date': '2026-10-01', # eerste geplande interventie 'notes': '3 Rational combi-steamers, keukenverdieping. Toegang via achterdeur.', }] )
Maak een agreement aan voor "Brasserie De Zwaan" — 2 combi-steamers, jaarlijks onderhoud. Stel next_date in op volgende maand. Klik "Genereer orders" in Odoo. Welke fsm.orders worden aangemaakt?
👁 Toon oplossing
De recurrence_type van een agreement bepaalt hoe vaak orders gegenereerd worden: daily, weekly, monthly, quarterly, yearly. Na elke ordergeneratie schuift next_date automatisch op. Een cron job in DispatchIQ checkt dagelijks welke agreements hun next_date bereikt hebben en genereert de volgende order. Dit voorkomt handmatig werk voor terugkerende klanten.
// Node.js cron: dagelijkse agreement check (elke ochtend 06:00) import cron from 'node-cron'; cron.schedule('0 6 * * *', async () => { const today = new Date().toISOString().slice(0, 10); const due = await odoo.searchRead( 'fieldservice.agreement', [['active', '=', true], ['next_date', '<=', today]], ['id', 'name', 'next_date'] ); console.log(`${due.length} agreements te verwerken`); for (const ag of due) { await odoo.execute('fieldservice.agreement', 'action_generate_orders', [[ag.id]]); console.log(`✓ ${ag.name} → order aangemaakt`); } });
Stel next_date van een agreement in op gisteren. Voer de cron job handmatig uit (test-aanroep). Verifieer dat de order aangemaakt is en dat next_date nu 1 jaar verder staat.
👁 Toon oplossing
Service Level Agreements definiëren de maximale responstijd en de prioriteit bij storingen. In DispatchIQ voeg je via een custom field sla_response_hours en sla_priority toe aan fieldservice.agreement. Bij het genereren van een fsm.order kopieert de automated action deze waarden naar het priority veld van de order. DispatchIQ sorteert dan op SLA-prioriteit bij het weergeven van de dagplanning.
# Custom SLA velden op fieldservice.agreement (via inheritance) class DispatchAgreement(models.Model): _inherit = 'fieldservice.agreement' sla_response_hours = fields.Integer('Max. responstijd (uren)', default=24) sla_priority = fields.Selection([ ('0', 'Standaard'), ('1', 'Urgent'), ('2', 'Kritisch 24/7'), ], default='0') def action_generate_orders(self): orders = super().action_generate_orders() # Kopieer SLA naar gegenereerde orders for order in orders: order.write({ 'priority': self.sla_priority, 'sla_deadline': fields.Datetime.now() + timedelta(hours=self.sla_response_hours) }) return orders
Voeg sla_response_hours en sla_priority toe aan het agreement model. Genereer een order van een urgent agreement (priority='1'). Verifieer dat de gegenereerde fsm.order priority='1' heeft. Hoe toont de Gantt dit visueel?
👁 Toon oplossing
Een horecazaak heeft doorgaans meerdere toestellen op dezelfde locatie. Het is inefficiënt om voor elk toestel een aparte interventie in te plannen. In DispatchIQ bundel je via een site agreement: één agreement per locatie dat orders aanmaakt voor meerdere apparaten tegelijk. De fsm.order bevat dan een relatie naar een horeca.equipment lijst. De werkbon PDF toont dan alle toestellen per bezoek gegroepeerd.
# Alle equipment op een locatie opvragen voor bundeling equipment_on_site = models.execute_kw(db, uid, pw, 'horeca.equipment', 'search_read', [['location_id', '=', hotel_location_id], ['state', '=', 'active']], {'fields': ['name', 'brand', 'refrigerant', 'serial_no']} ) print(f'{len(equipment_on_site)} toestellen op locatie Hotel Metropool') # Gebundelde order: alle equipment_ids meesturen bundled_order_id = models.execute_kw(db, uid, pw, 'fsm.order', 'create', [{ 'name': 'Hotel Metropool — Site visit Q1', 'location_id': hotel_location_id, 'equipment_ids': [(6, 0, [e['id'] for e in equipment_on_site])], }] )
Maak 3 horeca.equipment records aan op dezelfde locatie. Schrijf een script dat één gebundelde fsm.order aanmaakt voor alle 3 toestellen. Hoe toont de werkbon de 3 toestellen gescheiden? Welke checklist wordt gebruikt?
👁 Toon oplossing
DispatchIQ stuurt automatisch bevestigingsmails wanneer een interventie ingepland is, en herinneringsmails 24u en 2u voor de afspraak. Odoo heeft ingebouwde mail.template records die je koppelt aan een base.automation. De template gebruikt QWeb variabelen om orderdetails, techniekernaam en tijdslot te vermelden. Klanten ontvangen ook een link om de status live te volgen.
<!-- data/mail_templates.xml --> <record id="template_order_confirmed" model="mail.template"> <field name="name">FSM: Interventie bevestigd</field> <field name="model_id" ref="fieldservice.model_fsm_order"/> <field name="subject">Uw onderhoud op ${object.scheduled_date_start}</field> <field name="body_html" type="html"> <p>Geachte ${object.partner_id.name},</p> <p>Onze technieker <strong>${object.person_id.name}</strong> komt op <strong>${format_date(object.scheduled_date_start)}</strong>.</p> <p>Referentie: ${object.name}</p> </field> <field name="email_to">${object.partner_id.email}</field> </record>
Maak een mail template aan voor "Interventie bevestigd". Koppel hem aan een base.automation die triggert wanneer een order van stage "Nieuw" naar "Ingepland" gaat. Stuur een testmail. Controleer of de variabelen correct ingevuld zijn in de ontvangen mail.
👁 Toon oplossing
Het management wil weten: hoeveel actieve contracten lopen er, welke verlopen binnenkort, en welke klanten hebben al 6 maanden geen interventie gehad? DispatchIQ biedt een GET /api/agreements/report endpoint dat dit overzicht biedt. De data komt rechtstreeks uit Odoo via XML-RPC queries op fieldservice.agreement en fsm.order.
// Node.js: agreements rapport async function agreementsReport() { const today = new Date().toISOString().slice(0,10); const in60days = new Date(Date.now() + 60 * 86400000).toISOString().slice(0,10); const [active, expiringSoon, expired] = await Promise.all([ odoo.searchRead('fieldservice.agreement', [['active','=',true],['end_date','>',today]], ['name','partner_id','end_date','next_date']), odoo.searchRead('fieldservice.agreement', [['end_date','<=',in60days],['end_date','>',today]], ['name','partner_id','end_date']), odoo.searchRead('fieldservice.agreement', [['end_date','<=',today]], ['name','partner_id','end_date']), ]); return { active: active.length, expiringSoon, expired: expired.length }; }
Implementeer het GET /api/agreements/report endpoint. Maak daarvoor 3 test-agreements aan: één actief, één dat over 30 dagen verloopt, één al verlopen. Verifieer dat alle drie in de juiste categorie van het rapport vallen.
👁 Toon oplossing
In de horeca is de zomer (juli-augustus) de drukste periode voor koelinstallaties, maar de rustigste voor keukenrenovaties. Standaard recurrence is star (elke 6 maanden = exact 180 dagen). Met seizoenslogica stuur je de next_date actief bij: als het volgende onderhoudsmoment in augustus valt, schuif het op naar september. Dit implementeer je als een Python method die de gegenereerde next_date aanpast vóór de order aangemaakt wordt.
# Seizoensaanpassing: vermijd zomer (juli-aug) voor keukenwerk from datetime import date, timedelta def adjust_for_season(next_date, category): if category != 'kitchen': return next_date # Zomerperiode = juli (7) en augustus (8) if next_date.month in (7, 8): # Schuif op naar 1 september van hetzelfde jaar return date(next_date.year, 9, 1) return next_date # Omgekeerd: koelinstallaties EXTRA in zomer def cooling_season_extra(agreement): today = date.today() if today.month == 6 and agreement.category == 'cooling': # Voeg extra check in in juli toe return date(today.year, 7, 15) return agreement.next_date
Pas de action_generate_orders methode aan zodat hij adjust_for_season aanroept op de berekende next_date. Maak een agreement met next_date = 2026-07-15 (midden zomer). Genereer de order. Wordt de datum aangepast naar september?
👁 Toon oplossing
Als 50 agreements tegelijk hun next_date bereiken, worden er 50 orders aangemaakt op dezelfde dag — onmogelijk te plannen. Capaciteitsguardrails begrenzen hoeveel orders per dag/week automatisch gegenereerd mogen worden op basis van teamcapaciteit. Als de limiet bereikt is, schuift de volgende order op naar een vrije dag. Dit is een van de complexere automatiseringsfeatures, maar cruciaal voor productionele stabiliteit.
# Capaciteitscheck voor ordergeneratie def get_first_available_date(env, from_date, max_per_day=8): """Zoek eerste dag na from_date met minder dan max_per_day orders.""" check_date = from_date for _ in range(30): # max 30 dagen vooruit zoeken count = env['fsm.order'].search_count([ ('scheduled_date_start', '>=', check_date.strftime('%Y-%m-%d 00:00:00')), ('scheduled_date_start', '<', check_date.strftime('%Y-%m-%d 23:59:59')), ('stage_id.is_closed', '=', False), ]) if count < max_per_day: return check_date check_date += timedelta(days=1) if check_date.weekday() >= 5: # overslaan weekend check_date += timedelta(days=7 - check_date.weekday()) return None # geen vrije dag gevonden binnen 30 dagen
Simuleer een volle dag: maak 8 orders aan voor morgen. Roep daarna get_first_available_date(morgen, max_per_day=8) aan. Naar welke dag schuift hij? Wat als ook overmorgen vol is?
👁 Toon oplossing
# recurring agreement setup (conceptueel) agreement = env['fieldservice.agreement'].create({ 'name': 'Hotel Metropool Jaarcontract', 'recurrence_type': 'yearly', 'next_date': '2026-10-01', 'template_id': template_id }) agreement.action_generate_orders()
// Via Node.js: controleer verlopen contracten en genereer orders async function triggerOverdueAgreements() { const today = new Date().toISOString().slice(0, 10); // Haal actieve contracten op waarvan next_date <= vandaag const domain = [ ['active', '=', true], ['next_date', '<=', today], ]; const agreements = await client.execute( 'fieldservice.agreement', 'search_read', [domain], { fields: ['id', 'name', 'next_date', 'partner_id'] } ); console.log(`${agreements.length} contracten te genereren`); for (const ag of agreements) { await client.execute('fieldservice.agreement', 'action_generate_orders', [[ag.id]]); console.log(`✓ ${ag.name} — orders aangemaakt`); } }
Maak een jaarlijks onderhoudscontract voor een horeca-site en laat Odoo automatisch de volgende 2 interventies genereren.
Automatisering voorkomt dat belangrijke opvolgacties blijven liggen. In deze week bouw je server actions en cron jobs die DispatchIQ-data voortdurend gezond houden, inclusief alerts en herstelstappen bij afwijkingen.
Je werkt met idempotente jobs: een taak mag meerdere keren draaien zonder dubbele effecten. Dat is essentieel wanneer cronjobs herstarten na deploys of tijdelijke infrastructuurfouten.
Een server action (ir.actions.server) is een stuk Python code dat je kunt koppelen aan knoppen, menu-items, of automated actions. Het heeft toegang tot env, record (het huidige object), en records (meerdere geselecteerde records). Je gebruikt server actions voor logica die te complex is voor een automation filter alleen: "Als de order al 3 keer herpland is, stuur een alert naar de manager".
<!-- Server action via XML --> <record id="action_escalate_repeated_replan" model="ir.actions.server"> <field name="name">Escaleer herhaald herplannen</field> <field name="model_id" ref="fieldservice.model_fsm_order"/> <field name="state">code</field> <field name="code"> for order in records: replan_count = len(env['mail.message'].search([ ('res_id','=',order.id), ('body','ilike','herpland') ])) if replan_count >= 3: order.message_post( body=f'⚠ ORDER {order.name} werd al {replan_count}x herpland. Escalatie manager.', partner_ids=[env.ref('dispatch_iq_horeca.manager_partner').id] )</field> </record>
Maak een server action aan die een chatter-notitie plaatst op alle orders in stage "Wacht op stock" die ouder dan 5 dagen zijn. Test hem via de Odoo UI: selecteer meerdere orders → Action → kies jouw actie.
👁 Toon oplossing
base.automation koppelt een server action aan een event: on_create, on_write, on_unlink, of on_time. Het krachtigste pattern is on_write met een filter_pre_domain (vóór schrijven) en filter_domain (ná schrijven) — zo detecteer je exact wanneer een veld van waarde verandert. Voor DispatchIQ gebruik je dit om automatisch de herstelorder te herplannen zodra onderdelen aankomen.
<!-- Trigger: stage wijzigt naar "Wacht op stock" --> <record id="auto_waiting_for_stock" model="base.automation"> <field name="name">Order → Wacht op stock: notificeer dispatcher</field> <field name="model_id" ref="fieldservice.model_fsm_order"/> <field name="trigger">on_write</field> <field name="filter_pre_domain"> <!-- vóór schrijven: NIET op wacht --> [('stage_id.name','!=','Wacht op stock')] </field> <field name="filter_domain"> <!-- ná schrijven: WEL op wacht --> [('stage_id.name','=','Wacht op stock')] </field> <field name="action_server_id" ref="action_notify_dispatcher_stock"/> </record>
Maak een automated action die triggert wanneer een order van stage "Nieuw" naar "Ingepland" gaat. De actie moet een chatter-notitie plaatsen met de technieker en het geplande tijdslot. Test door een order manueel van stage te wisselen.
👁 Toon oplossing
Odoo scheduled actions (ir.cron) zijn jobs die op een vaste frequentie draaien. Ze worden gedefinieerd in XML als ir.cron records met een interval, een numbercall (-1 = oneindig) en een server action. Voor DispatchIQ maak je 3 crons: dagelijks een replenishment-check, wekelijks een dead-stock analyse, en elke ochtend een agenda-export naar de externe Gantt cache.
<!-- data/crons.xml --> <record id="cron_daily_replenishment_check" model="ir.cron"> <field name="name">DispatchIQ: Dagelijkse aanvulcheck voertuigen</field> <field name="model_id" ref="stock.model_stock_warehouse_orderpoint"/> <field name="state">code</field> <field name="code"> orderpoints = env['stock.warehouse.orderpoint'].search([ ('location_id.usage','=','internal'), ('location_id.name','ilike','VEH/') # enkel voertuiglocaties ]) orderpoints.action_replenish() env['ir.logging'].sudo().create({'name':'DispatchIQ Cron', 'type':'server','message':f'{len(orderpoints)} voertuigen gecontroleerd', 'path':'cron_replenishment','func':'run','line':1,'level':'info'}) </field> <field name="interval_number">1</field> <field name="interval_type">days</field> <field name="numbercall">-1</field> <field name="active" eval="True"/> </record>
Maak een cron job aan die elke dag om 07:00 controleert hoeveel orders de dag erna gepland staan en dit logt. Hoe trigger je de cron manueel in Odoo om te testen zonder te wachten tot morgen?
👁 Toon oplossing
Een notificatieflow koppelt een automated action aan een mail template. Het typische patroon is: status verandert → automated action triggert → server action roept template.send_mail(record.id) aan. In DispatchIQ stuur je zo: bevestiging bij inplanning (naar klant), reminder 24u voor interventie (naar klant + technieker), en escalatie bij overschrijding SLA deadline (naar manager).
# Server action code: template verzenden for record in records: # Stuur bevestiging naar klant template = env.ref('dispatch_iq_horeca.template_order_confirmed') template.send_mail(record.id, force_send=True) # Reminder: on_time trigger 24u voor scheduled_date_start <!-- In de automated action --> <field name="trigger">on_time</field> <field name="trg_date_id" ref="fieldservice.field_fsm_order__scheduled_date_start"/> <field name="trg_date_range">-24</field> <!-- 24u VOOR de datum --> <field name="trg_date_range_type">hours</field>
Bouw de 24u-reminder flow: automated action op on_time, 24u voor scheduled_date_start, stuurt een mail template naar de klant met datum en techniekernaam. Test door de scheduled_date_start te zetten op "nu + 23u" en de cron te triggeren.
👁 Toon oplossing
Externe API-calls vanuit Odoo (of vanuit je Node.js proxy) falen soms door netwerk timeouts of overbelasting. Een retry pattern herprobeert de call met exponentieel back-off: wacht 1s, 2s, 4s, 8s... tot een maximum. Gebruik een try/except blok in je server action of een tenacity decorator in Python. In Node.js gebruik je p-retry of een eenvoudige recursieve functie.
// Node.js: retry met exponentieel backoff async function withRetry(fn, maxAttempts = 3) { let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (err) { lastError = err; if (attempt < maxAttempts) { const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s console.warn(`Poging ${attempt} mislukt, retry na ${delay}ms`, err.message); await new Promise(r => setTimeout(r, delay)); } } } throw lastError; } // Gebruik: const orders = await withRetry(() => odoo.searchRead('fsm.order', ...));
Wrap je Odoo XML-RPC verbinding in de withRetry functie. Simuleer een fout door de Odoo URL tijdelijk fout te zetten. Ziet de console de retry-pogingen met toenemende delay? Wat logt de 3e gefaalde poging?
👁 Toon oplossing
In productie moet je weten wanneer een cron job of automated action faalt. Odoo logt errors naar /var/log/odoo/odoo.log en naar ir.logging in de database. DispatchIQ voegt een monitoring laag toe: elke cron schrijft een succes/fout-record naar een custom dispatch.job.log model, en een Node.js health-check endpoint pollt dit model regelmatig. Kritieke fouten triggeren een Telegram of e-mail alert.
// Node.js health monitor: check Odoo cron resultaten async function checkCronHealth() { const oneHourAgo = new Date(Date.now() - 3600000).toISOString().replace('T', ' ').slice(0,19); const failures = await odoo.searchRead('ir.logging', [ ['level', 'in', ['ERROR', 'CRITICAL']], ['create_date', '>=', oneHourAgo], ['name', 'ilike', 'DispatchIQ'], ], ['name', 'message', 'create_date']); if (failures.length > 0) { await sendAlert(`⚠ ${failures.length} DispatchIQ cron errors laatste uur`, failures.map(f => `${f.create_date}: ${f.message}`).join('\n')); } return { healthy: failures.length === 0, failures }; }
Schrijf een cron job die bewust een error gooit (raise Exception). Voer hem uit. Zoek het error-record in ir.logging via XML-RPC. Bouw daarna een simpele GET /health/crons endpoint die dit controleert en { healthy: true/false } retourneert.
👁 Toon oplossing
Een idempotente operatie geeft hetzelfde resultaat ongeacht hoe vaak je hem uitvoert. Bij cron jobs die herstarten na een deploy, of API calls die door een proxy opnieuw gestuurd worden, is dit essentieel. DispatchIQ genereert een idempotency-key per order-actie: een hash van orderId + action + date. Als de key al bestaat in een dispatch.processed_action tabel, skip je de actie. Zo worden herstelorders nooit twee keer aangemaakt voor dezelfde vaststelling.
// Node.js: idempotency check voor ordercreatie const crypto = require('crypto'); function makeKey(orderId, action, date) { return crypto.createHash('sha256') .update(`${orderId}-${action}-${date}`) .digest('hex') .slice(0, 32); } async function idempotentCreate(orderId, action, createFn) { const key = makeKey(orderId, action, new Date().toISOString().slice(0, 10)); const existing = await db.get('SELECT id FROM processed_actions WHERE key=?', [key]); if (existing) { console.log(`Idempotency hit: ${key} — skip`); return { skipped: true, key }; } const result = await createFn(); await db.run('INSERT INTO processed_actions(key,result) VALUES(?,?)', [key, JSON.stringify(result)]); return result; }
Roep idempotentCreate twee keer aan voor dezelfde order op dezelfde dag. Wat retourneert de tweede aanroep? Welke orders staan in Odoo? Bewijs dat er geen duplicate aangemaakt werd.
👁 Toon oplossing
Een dead-letter queue is een opvangmechanisme voor records die herhaaldelijk falen: ze worden apart bewaard voor manuele opvolging in plaats van eindeloos opnieuw geprobeerd te worden. In DispatchIQ bewaar je mislukte acties in een dispatch.failed_action model met velden: record_model, record_id, action, error_message, retry_count. De dispatcher ziet deze records in een apart dashboard en kan ze handmatig herproberen of archiveren.
# Python: dead-letter opvang in server action def safe_execute_action(env, record, action_fn): try: action_fn(record) except Exception as e: # Bewaar in dead-letter tabel existing = env['dispatch.failed.action'].search([ ('record_model', '=', record._name), ('record_id', '=', record.id), ('action', '=', action_fn.__name__), ], limit=1) if existing: existing.write({'retry_count': existing.retry_count + 1, 'last_error': str(e)}) else: env['dispatch.failed.action'].create({ 'record_model': record._name, 'record_id': record.id, 'action': action_fn.__name__, 'last_error': str(e), 'retry_count': 1, })
Definieer het model dispatch.failed.action met de velden hierboven. Roep safe_execute_action aan met een action die opzettelijk faalt. Verifieer dat het record aangemaakt werd. Roep de actie daarna opnieuw aan — stijgt retry_count?
👁 Toon oplossing
# cron pseudo-code: open vaststellingen ouder dan 7 dagen orders = env['fsm.order'].search([ ('type','=','vaststelling'), ('stage_id.is_closed','=',False), ('create_date','<', fields.Datetime.now() - timedelta(days=7)) ]) for order in orders: order.message_post(body='Escalatie: order ouder dan 7 dagen')
<!-- data/automated_actions.xml -- server action via XML --> <record id="action_auto_escalate" model="base.automation"> <field name="name">Escaleer vaststellingen > 7 dagen</field> <field name="model_id" ref="fieldservice.model_fsm_order"/> <field name="trigger">on_time</field> <field name="filter_pre_domain">[ ('type','=','vaststelling'), ('stage_id.is_closed','=',False) ]</field> <field name="trg_date_id" ref="base.field_fsm_order__create_date"/> <field name="trg_date_range">7</field> <field name="trg_date_range_type">days</field> <field name="action_server_id"> <record model="ir.actions.server"> <field name="state">code</field> <field name="code"> for order in records: order.message_post( body='⚠ Escalatie: vaststelling ouder dan 7 dagen', message_type='comment', subtype_xmlid='mail.mt_note' )</field> </record> </field> </record>
data/automated_actions.xml zodat ze versiegecontroleerd zijn en bij -u module correct herladen worden.Automatiseer een flow die herstelorders aanmaakt wanneer onderdelen geleverd zijn en de oorspronkelijke vaststelling op "Wacht op stock" staat.
Dit sluitstuk verbindt configuratie met softwarearchitectuur. Je voegt gerichte velden toe in Odoo en exposeert ze via één adapterlaag, zodat frontend en backend consequent blijven praten over dezelfde constraints.
Je definieert hier ook stabiliteitsgaranties: welke velden zijn publiek contract, welke zijn intern. Die grens voorkomt dat frontend per ongeluk afhankelijk wordt van fragiele implementatiedetails.
De drie meest kritieke custom velden voor DispatchIQ worden via Python inheritance toegevoegd aan bestaande OCA modellen. refrigerant_type op horeca.equipment (R32/R290/R134a) bepaalt welk certificaat een technieker nodig heeft. lez_zone op fsm.location bepaalt welk voertuig de locatie mag binnenkomen. site_access_required op fsm.location markeert locaties waar een sleutelcode of host aanwezig moet zijn. Al deze velden worden bevraagd door de constraints engine bij elke toewijzing.
# models/fsm_order_extension.py class FsmOrderExtension(models.Model): _inherit = 'fsm.order' refrigerant_type = fields.Selection([ ('R32', 'R32 (GWP 675)'), ('R290', 'R290 Propaan'), ('R134a', 'R134a'), ('none', 'Geen koelmiddel'), ], string='Koelmiddel type', default='none') class FsmLocationExtension(models.Model): _inherit = 'fsm.location' lez_zone = fields.Selection([ ('none', 'Geen LEZ'), ('antwerp', 'LEZ Antwerpen'), ('ghent', 'LEZ Gent'), ('brussels', 'LEZ Brussel'), ], string='LEZ Zone', default='none') site_access_required = fields.Boolean('Sleutelcode vereist')
Voeg de 3 custom velden toe via inheritance. Installeer de module. Stel op 2 locaties lez_zone in (één Antwerpen, één geen). Zoek via XML-RPC alle locaties in de LEZ van Antwerpen. Hoeveel zijn er?
👁 Toon oplossing
Custom velden zijn pas bruikbaar als ze ook zichtbaar zijn in de Odoo UI. Je gebruikt view inheritance met xpath om ze toe te voegen aan bestaande OCA views zonder de originele bestanden te wijzigen. Voor refrigerant_type voeg je een nieuw tabblad "Horeca" toe aan het fsm.order formulier. Voor lez_zone voeg je het toe aan het locatieformulier. Dit maakt de velden bereikbaar voor dispatchers én zichtbaar in de werkbon PDF.
<!-- views/fsm_order_horeca_views.xml --> <record id="view_fsm_order_form_horeca_ext" model="ir.ui.view"> <field name="inherit_id" ref="fieldservice.view_fsm_order_form"/> <field name="arch" type="xml"> <xpath expr="//notebook" position="inside"> <page string="Horeca details"> <group string="Koeltechniek"> <field name="refrigerant_type"/> <field name="equipment_id"/> <!-- horeca.equipment --> </group> <group string="Toegang"> <field name="location_id.lez_zone" readonly="1"/> <field name="location_id.site_access_required" readonly="1"/> </group> </page> </xpath> </field> </record>
Voeg het "Horeca details" tabblad toe aan de fsm.order form view. Open een order in Odoo — zie je het tabblad? Stel refrigerant_type = R32 in. Verificeer via XML-RPC dat de waarde opgeslagen werd.
👁 Toon oplossing
Het adapter pattern isoleert de DispatchIQ business logic van de Odoo-specifieke implementatie. De interface IDispatchAdapter definieert de vier constraints als abstracte methoden. De OdooAdapter implementeert deze methoden via XML-RPC. Zo kan je in tests een MockAdapter gebruiken zonder echte Odoo-verbinding. En als de constraints ooit naar een andere bron moeten (bijv. een externe skills-database), wissel je enkel de adapter, niet de business logic.
// src/adapters/IDispatchAdapter.js class IDispatchAdapter { async checkParts(orderId, techId) { throw new Error('Not implemented'); } async checkSkills(orderId, techId) { throw new Error('Not implemented'); } async checkAccess(orderId, techId) { throw new Error('Not implemented'); } async checkZone(orderId, vehicleId) { throw new Error('Not implemented'); } async checkAll(orderId, techId, vehicleId) { const [parts, skills, access, zone] = await Promise.all([ this.checkParts(orderId, techId), this.checkSkills(orderId, techId), this.checkAccess(orderId, techId), this.checkZone(orderId, vehicleId), ]); return { ok: parts.ok && skills.ok && access.ok && zone.ok, parts, skills, access, zone }; } }
Maak een MockAdapter die IDispatchAdapter uitbreidt. checkParts retourneert altijd { ok: true }. checkSkills retourneert altijd { ok: false, missing: ['R32-certificaat'] }. Test checkAll — wat is result.ok?
👁 Toon oplossing
De OdooAdapter implementeert elke constraint als een afzonderlijke methode die de juiste Odoo XML-RPC calls doet. checkSkills vergelijkt de vereiste skill-tags op de order met de skill-tags van de technieker. checkAccess controleert of de locatie een sleutelcode vereist en of de dispatcher die al ingevoerd heeft. checkZone vergelijkt de LEZ-zone van de locatie met de voertuig-tags. Elke check retourneert hetzelfde basisformaat: { ok: bool, reason?: string }.
// OdooAdapter.checkSkills implementatie async checkSkills(orderId, techId) { const [order, tech] = await Promise.all([ odoo.read('fsm.order', [orderId], ['skill_ids', 'refrigerant_type']), odoo.read('fsm.person', [techId], ['skill_ids']), ]); const required = new Set(order.skill_ids); const has = new Set(tech.skill_ids); const missing = [...required].filter(s => !has.has(s)); if (order.refrigerant_type === 'R32' && !tech.skill_ids.includes('R32_CERT')) missing.push('R32-certificaat (F-gas)'); return { ok: missing.length === 0, missing }; }
Implementeer ook checkZone: haal fsm.location.lez_zone op van de order, vergelijk met de voertuig-tags van de technieker. Toon een testcase waarbij de order in "LEZ Antwerpen" ligt maar het voertuig geen "LEZ-Antwerpen" tag heeft.
👁 Toon oplossing
De frontend heeft één consistente response-structuur nodig voor alle constraint-types. In plaats van per constraint een eigen formaat te hebben, definieer je een ConstraintResult type dat overal hetzelfde is: { ok: bool, type: string, severity: 'block'|'warn', details: any }. severity: 'block' verhindert de toewijzing volledig (bijv. geen rijbewijs). severity: 'warn' toont een waarschuwing maar staat de dispatcher toe te overschrijven (bijv. parts tekort maar dispatcher neemt bewuste beslissing).
// Uniforme ConstraintResult wrapper function constraintResult(type, ok, details, severity = 'warn') { return { ok, type, severity, details }; } // Gebruik in alle check methods: async checkZone(orderId, vehicleId) { // ... zone check logica ... if (!hasTag) return constraintResult('zone', false, { zone: lez, vehicle: vehicleId }, 'block'); // HARD block return constraintResult('zone', true, {}); } async checkParts(orderId, techId) { // ... parts check ... if (missing.length) return constraintResult('parts', false, { missing }, 'warn'); // SOFT warn — dispatcher mag overschrijven return constraintResult('parts', true, {}); }
Pas alle 4 check methods aan zodat ze constraintResult gebruiken. Stel in welke hard block zijn (zone, skills met veiligheidscertificaat) en welke warn zijn (parts, gewone skills). Hoe reageert de Gantt UI anders op block vs warn?
👁 Toon oplossing
Voor de Milestone 2 demo moet je live kunnen tonen dat alle 4 constraints werken. Bereid een gecontroleerde dataset voor: 3 techniekers (elk met andere skills en voertuig-tags), 4 orders (elk met andere vereisten die leiden tot different constraint outcomes). De demo toont: 1 succesvolle toewijzing, 1 parts-waarschuwing (maar doorgezet), 1 skills-blokkering en 1 LEZ-blokkering. Dit bewijst dat het systeem werkt en niet alleen "gelukkig" toewijst.
// Demo dataset script — uitvoeren vóór de presentatie const DEMO_SCENARIOS = [ { orderId: 1, techId: 1, expected: 'OK', desc: 'Alles correct' }, { orderId: 2, techId: 2, expected: 'WARN_PARTS', desc: 'Parts tekort' }, { orderId: 3, techId: 3, expected: 'BLOCK_SKILLS', desc: 'R32 certificaat ontbreekt' }, { orderId: 4, techId: 1, expected: 'BLOCK_ZONE', desc: 'Voertuig mag LEZ Antwerpen niet in' }, ]; for (const s of DEMO_SCENARIOS) { const result = await adapter.checkAll(s.orderId, s.techId, s.vehicleId); console.log(`${s.desc}: ok=${result.ok}`, result.ok ? '✅' : `❌ ${JSON.stringify(result)}`); }
Stel de demo dataset op in Odoo. Voer het DEMO_SCENARIOS script uit. Kloppen alle 4 scenario's met de verwachte uitkomst? Als niet, debuggen: welke data klopt niet in Odoo?
👁 Toon oplossing
Als je de constraint API uitbreidt (bijv. een 5e check toevoegt), mogen bestaande frontend-consumers niet breken. Versioning in de URL (/api/v1/constraints vs /api/v2/constraints) is de meest expliciete aanpak. Alternatief: additive versioning waarbij je enkel velden toevoegt maar nooit verwijdert of hernoemt. DispatchIQ gebruikt de additive aanpak voor constraint results: elk nieuw veld is optioneel, en de frontend handelt ontbrekende velden af met defaults.
// API v1 (bestaand) — alle consumers kennen dit // GET /api/v1/constraints/check → { ok, parts, skills } // API v2 (uitbreiding) — voegt access en zone toe // GET /api/v2/constraints/check → { ok, parts, skills, access, zone } // Express router versioning const v1 = express.Router(); const v2 = express.Router(); v1.get('/constraints/check', async (req, res) => { const { orderId, techId } = req.query; const result = await adapter.checkAll(orderId, techId); res.json({ ok: result.ok, parts: result.parts, skills: result.skills }); // v1: enkel 2 checks }); v2.get('/constraints/check', async (req, res) => { const { orderId, techId, vehicleId } = req.query; const result = await adapter.checkAll(orderId, techId, vehicleId); res.json(result); // v2: alle 4 checks }); app.use('/api/v1', v1); app.use('/api/v2', v2);
Implementeer zowel /api/v1/constraints/check als /api/v2/constraints/check. Verifieer via curl dat v1 enkel parts+skills retourneert en v2 alle 4. Schrijf een test die beide versies aanroept en hun schema valideert.
👁 Toon oplossing
Elke constraint-call in DispatchIQ wordt voorzien van een trace-id (unieke ID per request) en latency logging. Als de Gantt traag is of een constraint altijd faalt, zoek je via de trace-id exact op welke Odoo call de bottleneck veroorzaakte. De trace-id wordt meegegeven als HTTP header X-Trace-Id en opgenomen in alle Odoo-logs van die request. Prometheus metrics tellen hoeveel checks per seconde draaien en hoe lang ze duren.
// Observability middleware voor constraints import { randomUUID } from 'crypto'; function withObservability(checkFn, type) { return async (...args) => { const traceId = randomUUID(); const start = Date.now(); try { const result = await checkFn(...args); const ms = Date.now() - start; logger.info({ traceId, type, ms, ok: result.ok }, 'constraint check'); metrics.histogram(`constraint_latency_ms`, ms, { type }); return { ...result, traceId }; } catch (err) { logger.error({ traceId, type, error: err.message }, 'constraint failed'); metrics.increment('constraint_error_total', { type }); throw err; } }; }
Wrap alle 4 check methods met withObservability. Voer 10 constraint checks uit. Bekijk de logs — wat is de gemiddelde latency van checkParts vs checkSkills? Welke check is het traagst en waarom?
👁 Toon oplossing
// IDispatchAdapter contract interface IDispatchAdapter { checkParts(orderId: number, techId: number): Promise<ConstraintResult>; checkSkills(orderId: number, techId: number): Promise<ConstraintResult>; checkAccess(orderId: number, techId: number): Promise<ConstraintResult>; checkZone(orderId: number, vehicleId: number): Promise<ConstraintResult>; }
Alle DispatchIQ constraints werken via echte Odoo data en worden in de planner als duidelijke waarschuwingen getoond.
Odoo Addon
Development
Odoo uitbreiden met Python en XML. Eigen module bouwen. ORM, computed fields, constraints, wizards, PDF rapporten. Dit maakt jou een developer, geen configurator.
In week 25 zet je de stap van configuratie naar echte ontwikkeling. Je bouwt een module met duidelijke structuur en een robuust ORM-model, zodat latere features zoals wizards, views en tests op een solide basis landen.
Je behandelt module-opbouw als softwareproduct: naming conventions, migratiepad en testbaarheid worden vanaf de eerste commit mee ontworpen. Dat voorkomt dat je later moet refactoren onder tijdsdruk.
Elke Odoo module is een Python package met een vaste mappenstructuur. Odoo verwacht deze structuur exact: als een bestand op de verkeerde plek staat, laadt het gewoon niet. __init__.py importeert de Python submodules. __manifest__.py beschrijft de module (naam, versie, dependencies, te laden bestanden). models/ bevat de datamodellen. views/ bevat de XML UI-definities. security/ bevat de toegangsregels. data/ bevat initiële data. tests/ bevat unit tests.
# dispatch_iq_horeca/ mappenstructuur dispatch_iq_horeca/ ├── __init__.py # from . import models ├── __manifest__.py # module metadata ├── models/ │ ├── __init__.py # from . import horeca_equipment, fsm_order_ext │ ├── horeca_equipment.py # class HorecaEquipment │ ├── fsm_order_ext.py # class FsmOrderExtension (_inherit) │ └── dispatch_skill.py # class DispatchSkill ├── views/ │ ├── horeca_equipment_views.xml │ └── fsm_order_horeca_views.xml # inheritance van OCA view ├── security/ │ ├── groups.xml │ └── ir.model.access.csv ├── data/ │ └── fsm_stage_data.xml # horeca-specifieke stages ├── report/ │ └── werkbon_template.xml # QWeb PDF rapport └── tests/ ├── __init__.py └── test_skill_check.py
Maak de volledige mappenstructuur aan voor dispatch_iq_horeca — alle mappen en lege bestanden. Maak een minimale __manifest__.py en lege __init__.py bestanden. Voeg de module toe aan addons_path en verifieer dat hij verschijnt in de App-lijst (zelfs nog niet geïnstalleerd).
👁 Toon oplossing
Het __manifest__.py bestand is een Python dictionary met alle metadata van de module. Odoo leest dit vóór enig ander bestand. De data lijst bepaalt de laadvolgorde van XML-bestanden — dit is kritiek: security vóór views, views vóór data, data vóór automated actions. Een verkeerde volgorde in data geeft cryptische "xmlid not found" errors bij installatie.
# Volledige __manifest__.py met alle praktische velden { 'name': 'DispatchIQ Horeca', 'version': '17.0.1.0.0', # altijd Odoo-versie als prefix 'summary': 'Horeca field service module voor DispatchIQ', 'author': 'TechniCool NV', 'website': 'https://dispatchiq.be', 'license': 'LGPL-3', 'category': 'Field Service', 'depends': ['fieldservice', 'fieldservice_stock', 'fleet', 'mail'], 'data': [ 'security/groups.xml', # 1. groepen eerst 'security/ir.model.access.csv', # 2. dan toegangsrechten 'views/horeca_equipment_views.xml', # 3. views 'views/fsm_order_horeca_views.xml', 'report/werkbon_template.xml', # 4. rapporten 'data/fsm_stage_data.xml', # 5. initiële data 'data/automated_actions.xml', # 6. automaties als laatste ], 'demo': ['demo/demo_horeca.xml'], # optioneel: testdata 'installable': True, 'auto_install': False, }
Wissel de volgorde in data: zet automated_actions.xml vóór ir.model.access.csv. Probeer de module te installeren. Welke fout krijg je? Herstel de volgorde. Wat leert dit je over de laadvolgorde?
👁 Toon oplossing
Een Odoo model is een Python klasse die erft van models.Model. De klasse-attribuut _name bepaalt de naam in de database en in XML-RPC. _description is de leesbare naam. _inherit laat je een bestaand model uitbreiden zonder te forken. Door mail.thread en mail.activity.mixin toe te voegen aan _inherit, krijgt het model automatisch een chatter met tracking.
# models/horeca_equipment.py from odoo import models, fields, api from odoo.exceptions import ValidationError import logging _logger = logging.getLogger(__name__) class HorecaEquipment(models.Model): _name = 'horeca.equipment' _description = 'Horeca Apparatuur' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'name asc' # standaard sortering _rec_name = 'name' # welk veld gebruikt wordt als display naam name = fields.Char('Naam', required=True, tracking=True) serial_no = fields.Char('Serienummer', tracking=True) brand = fields.Selection([ ('rational', 'Rational'), ('electrolux', 'Electrolux'), ('mkn', 'MKN'), ('other', 'Andere'), ], string='Merk', required=True) location_id = fields.Many2one('fsm.location', 'Locatie', ondelete='set null') state = fields.Selection([ ('active', 'Actief'), ('defect', 'Defect'), ('retired', 'Uitgedienst') ], default='active', tracking=True)
Maak de horeca_equipment.py klasse aan met alle velden hierboven. Importeer hem in models/__init__.py. Installeer de module. Ga in Odoo naar Technisch → Database Structuur → Modellen → zoek "horeca.equipment". Welke kolommen zie je in de database?
👁 Toon oplossing
Odoo heeft een rijke set veldtypes. De keuze bepaalt hoe data opgeslagen, gevalideerd en getoond wordt. Char is voor korte tekst (max 255 chars). Text voor lange tekst zonder limiet. Selection voor een vaste lijst keuzes. Many2one is een FK naar een ander model (één-op-veel). One2many is de inverse kant. Many2many voor veel-op-veel. Binary voor bestanden of afbeeldingen (base64). De meest gemaakte fout: One2many gebruiken waar Many2many bedoeld is.
# Alle veldtypes in context van horeca.equipment class HorecaEquipment(models.Model): _name = 'horeca.equipment' name = fields.Char('Naam', size=128) # varchar(128) notes = fields.Text('Notities') # text, geen limiet year = fields.Integer('Bouwjaar') # integer voltage = fields.Float('Voltage', digits=(6,1)) # 6 cijfers, 1 decimaal in_service = fields.Boolean('In dienst', default=True) install_date = fields.Date('Installatiedatum') last_service = fields.Datetime('Laatste onderhoud') photo = fields.Binary('Foto', attachment=True) # opgeslagen als ir.attachment brand = fields.Selection([('rational','Rational'),...]) location_id = fields.Many2one('fsm.location') # FK → 1 locatie service_ids = fields.One2many('fsm.order','equipment_id') # inverse FK skill_ids = fields.Many2many('dispatch.skill') # M2M → meerdere skills description = fields.Html('Beschrijving') # rich text (WYSIWYG)
Voeg een photo Binary veld toe aan je model. Laad een foto via de Odoo UI. Lees de waarde via XML-RPC op. Wat is het formaat van de waarde? Hoe toon je de foto in een QWeb template?
👁 Toon oplossing
Een computed field berekent zijn waarde automatisch op basis van andere velden. Je declareert hem met compute='_method_naam' en de methode gebruikt @api.depends('veld1', 'veld2') om dependencies te declareren. Zonder store=True wordt de waarde berekend bij elke lees-operatie en niet in de database bewaard — handig voor realtime data maar traag bij veel records. Met store=True wordt de waarde gecached in de database en bijgewerkt zodra een dependency wijzigt.
# Computed fields op horeca.equipment service_count = fields.Integer( compute='_compute_service_count', store=True) last_service = fields.Datetime( compute='_compute_last_service', store=True) age_years = fields.Float( compute='_compute_age', store=False) # altijd vers berekend @api.depends('service_ids') def _compute_service_count(self): for rec in self: rec.service_count = len(rec.service_ids) @api.depends('service_ids.scheduled_date_start') def _compute_last_service(self): for rec in self: done = rec.service_ids.filtered(lambda s: s.stage_id.is_closed) rec.last_service = max(done.mapped('scheduled_date_start'), default=False) @api.depends('install_date') def _compute_age(self): for rec in self: if rec.install_date: rec.age_years = (fields.Date.today() - rec.install_date).days / 365.25 else: rec.age_years = 0.0
Voeg service_count toe als computed field. Maak 3 fsm.orders aan die linken naar hetzelfde equipment. Lees service_count via XML-RPC — is het 3? Wat is het verschil in de database als store=True vs store=False?
👁 Toon oplossing
@api.constrains valideert data bij elke create en write operatie. Als de validatie faalt, gooi je een ValidationError en slaat Odoo de record niet op. SQL constraints (_sql_constraints) gaan nog dieper: ze worden direct in PostgreSQL aangemaakt als UNIQUE of CHECK constraints. Het voordeel van SQL constraints is dat ze ook gelden bij directe database-inserts (imports, scripts), niet enkel via de ORM.
# Python constraint + SQL constraint op horeca.equipment class HorecaEquipment(models.Model): _name = 'horeca.equipment' # SQL constraint: serienummer uniek per bedrijf _sql_constraints = [ ('unique_serial', 'UNIQUE(serial_no, company_id)', 'Serienummer moet uniek zijn binnen het bedrijf.'), ] @api.constrains('serial_no') def _check_serial_length(self): for rec in self: if rec.serial_no and len(rec.serial_no) < 5: raise ValidationError( f'Serienummer "{rec.serial_no}" is te kort. Minimum 5 tekens vereist.' ) @api.constrains('install_date') def _check_install_date(self): for rec in self: if rec.install_date and rec.install_date > fields.Date.today(): raise ValidationError('Installatiedatum kan niet in de toekomst liggen.')
Probeer een equipment aan te maken met serial_no = 'AB' (2 tekens). Welke fout zie je in de UI? Probeer daarna 2 equipment records aan te maken met hetzelfde serienummer. Welke error? SQL constraint vs Python constraint — welke fout is "mooier"?
👁 Toon oplossing
Door create(), write() en unlink() te overriden kan je extra logica uitvoeren bij elke database-operatie. De gouden regel: roep altijd super() aan — als je dat vergeet, worden records niet opgeslagen. Gebruik create() override voor het zetten van standaard waarden die berekend moeten worden uit externe bronnen. Gebruik write() voor het loggen of synchroniseren van wijzigingen naar externe systemen.
# CRUD overrides op horeca.equipment @api.model def create(self, vals): # Genereer interne code als niet meegegeven if not vals.get('internal_code'): seq = self.env['ir.sequence'].next_by_code('horeca.equipment.seq') vals['internal_code'] = seq record = super().create(vals) _logger.info(f'HorecaEquipment aangemaakt: {record.name} ({record.internal_code})') return record def write(self, vals): # Als state naar 'retired' gaat: log dit if vals.get('state') == 'retired': for rec in self: rec.message_post(body=f'Apparaat {rec.name} buiten dienst gesteld.') return super().write(vals) def unlink(self): # Verhinder verwijdering van apparaten met openstaande orders for rec in self: if rec.service_ids.filtered(lambda o: not o.stage_id.is_closed): raise UserError(f'Kan {rec.name} niet verwijderen: heeft nog open orders.') return super().unlink()
Overschrijf unlink() zodat je geen equipment kan verwijderen als er open fsm.orders aan gekoppeld zijn. Maak een equipment aan met 1 open order. Probeer het equipment te verwijderen. Wat ziet de gebruiker? Sluit de order af en probeer opnieuw.
👁 Toon oplossing
Elke Python module in Odoo heeft zijn eigen logger via logging.getLogger(__name__). Dit geeft logs zoals odoo.addons.dispatch_iq_horeca.models.horeca_equipment — zo kan je snel filteren in productie-logs. Gebruik _logger.debug voor development details (niet in productie), _logger.info voor business events, _logger.warning voor onverwachte situaties die niet fataal zijn, en _logger.error voor fouten die actie vereisen.
# Logging standaard in elke module file import logging _logger = logging.getLogger(__name__) # __name__ = 'odoo.addons.dispatch_iq_horeca.models.horeca_equipment' class HorecaEquipment(models.Model): _name = 'horeca.equipment' def action_service_complete(self): for rec in self: _logger.info( 'Service voltooid op apparaat %s (ID: %d) door gebruiker %s', rec.name, rec.id, self.env.user.name ) # String formatting met % is efficiënter dan f-strings voor logging # (vermijdt string-opbouw als log level uitgeschakeld is) if not rec.last_service: _logger.warning( 'Apparaat %s heeft geen last_service datum — mogelijke data-inconsistentie', rec.name )
Voeg logging toe aan je create() override. Maak een equipment aan. Ga naar Instellingen → Technisch → Logging en filter op jouw module naam. Zie je de log entry? Wat is het volledige logger-pad?
👁 Toon oplossing
Wanneer je een nieuwe versie van je module uitbrengt (bijv. 17.0.1.1.0), kan Odoo automatisch een migratiescript uitvoeren via de migrations/ map. Een migratiescript is een Python bestand genaamd 17.0.1.1.0/post-migrate.py dat data aanpast die niet automatisch door ORM-changes afgehandeld wordt. Zonder migratiescripts kunnen bestaande records in de verkeerde state blijven na een module-update in productie.
# migrations/17.0.1.1.0/post-migrate.py # Uitvoerd na -u dispatch_iq_horeca als nieuwe versie hoger is dan geïnstalleerde def migrate(cr, version): if not version: return # eerste installatie, geen migratie nodig # Vul nieuw veld 'refrigerant_type' in voor bestaande equipment cr.execute(""" UPDATE horeca_equipment SET refrigerant_type = 'R32' WHERE refrigerant IS NOT NULL AND refrigerant ILIKE '%R32%' AND refrigerant_type IS NULL """) # Log hoeveel records bijgewerkt werden cr.execute("SELECT COUNT(*) FROM horeca_equipment WHERE refrigerant_type = 'R32'") count = cr.fetchone()[0] print(f'Migratie 17.0.1.1.0: {count} equipment records bijgewerkt met R32 type')
Verhoog de versie in __manifest__.py van 17.0.1.0.0 naar 17.0.1.1.0. Maak het migratiemap en -bestand aan. Voer een update uit. Controleer of het migratiescript uitgevoerd werd door het SELECT statement te monitoren in de logs.
👁 Toon oplossing
# models/horeca_equipment.py from odoo import models, fields, api from odoo.exceptions import ValidationError class HorecaEquipment(models.Model): _name = 'horeca.equipment' _description = 'Horeca Apparatuur' _inherit = ['mail.thread', 'mail.activity.mixin'] name = fields.Char('Naam', required=True) brand = fields.Selection([ ('rational', 'Rational'), ('electrolux', 'Electrolux'), ('mkn', 'MKN'), ('other', 'Andere') ], required=True) refrigerant = fields.Char('Koelmiddel') serial_no = fields.Char('Serienummer') location_id = fields.Many2one('fsm.location', 'Locatie') service_ids = fields.One2many('fsm.order', 'equipment_id', 'Interventies') service_count = fields.Integer(compute='_compute_service_count', store=True) @api.depends('service_ids') def _compute_service_count(self): for rec in self: rec.service_count = len(rec.service_ids)
# __manifest__.py — verplichte structuur { 'name': 'DispatchIQ Horeca', 'version': '17.0.1.0.0', # Odoo-versie.major.minor.patch 'summary': 'Horeca field service extensies voor DispatchIQ', 'author': 'TechniCool NV', 'license': 'LGPL-3', 'depends': [ 'fieldservice', # OCA base fieldservice 'fieldservice_stock', # OCA parts flow 'mail', # voor mail.thread en chatter ], 'data': [ 'security/ir.model.access.csv', 'views/horeca_equipment_views.xml', 'views/fsm_order_views.xml', 'report/werkbon_template.xml', 'data/automated_actions.xml', ], 'installable': True, 'application': False, }
data telt: ir.model.access.csv moet altijd als eerste komen — anders faalt de installatie omdat de security records verwijzen naar modellen die nog geladen worden. CSV voor views, views voor data, data voor automated_actions.Maak de module dispatch_iq_horeca aan. Definieer horeca.equipment met alle velden. Voeg een computed field last_service_date toe. Schrijf een Python constraint die verhindert dat serienummer minder dan 5 karakters is. Installeer de module zonder errors.
In week 26 focus je op betrouwbaarheid: afgeleide velden en harde validaties. DispatchIQ moet fouten vroeg blokkeren, anders krijg je onbruikbare planning- of factuurdata.
Je beslist expliciet welke validatieregels op UI-niveau horen en welke absoluut op modelniveau moeten staan. Zo blijven regels afdwingbaar via API, import en background jobs.
@api.depends markeert een methode als computed field handler. Odoo herberekent de waarde automatisch zodra één van de genoemde afhankelijke velden wijzigt. Voor fsm.order gebruik je dit typisch om afgeleide statusindicatoren of totaalvelden bij te houden, zoals parts_total op basis van de orderregels.
# models/fsm_order_extension.py from odoo import models, fields, api class FsmOrderExtension(models.Model): _inherit = 'fsm.order' parts_total = fields.Float( string='Onderdelen totaal', compute='_compute_parts_total', store=True ) @api.depends('product_ids.price_unit', 'product_ids.qty_done') def _compute_parts_total(self): for order in self: order.parts_total = sum( l.price_unit * l.qty_done for l in order.product_ids )
Voeg een computed field is_high_value toe (Boolean) dat True is als parts_total > 500. Controleer dat het veld in de UI herberekent zodra je een onderdeel aanpast.
👁 Toon oplossing
Zonder store=True berekent Odoo het veld enkel bij het laden van een record in Python — het staat niet in de database. Met store=True wordt de waarde opgeslagen en kan je erop filteren, sorteren en in list views tonen zonder extra queries. De tradeoff: elke wijziging in de dependency triggert een DB-schrijfoperatie. Voor parts_total is opslaan zinvol; voor een realtime tijdstempel niet.
# store=False (default): altijd berekend, nooit in DB response_time_hours = fields.Float( compute='_compute_response_time' # niet doorzoekbaar, niet in list view tenzij met context trick ) # store=True: in DB, doorzoekbaar, sorteerbaar parts_total = fields.Float( compute='_compute_parts_total', store=True # wordt herschreven bij elke wijziging van product_ids )
Welk computed field zou je niet met store=True opslaan en waarom? Denk aan een veld dat de huidige tijdzone van de klant weergeeft.
👁 Toon oplossing
@api.constrains wordt aangeroepen bij create én write, ook via XML-RPC en bulk imports. Als de regel geschonden is, gooi je een ValidationError — Odoo toont die als rode foutmelding en rolt de transactie terug. Voor DispatchIQ gebruik je dit om te verhinderen dat een order gepland wordt op een moment dat het apparaat al een actieve order heeft.
from odoo.exceptions import ValidationError class FsmOrderExtension(models.Model): _inherit = 'fsm.order' @api.constrains('scheduled_date_start', 'scheduled_date_end', 'equipment_id') def _check_no_equipment_overlap(self): for order in self: if not order.equipment_id: continue overlap = self.env['fsm.order'].search([ ('id', '!=', order.id), ('equipment_id', '=', order.equipment_id.id), ('scheduled_date_start', '<', order.scheduled_date_end), ('scheduled_date_end', '>', order.scheduled_date_start), ], limit=1) if overlap: raise ValidationError( 'Toestel %s heeft al een order in dit tijdvenster: %s' % (order.equipment_id.name, overlap.name) )
Schrijf een constraint die verhindert dat een technieker meer dan 3 orders op dezelfde dag heeft. Hint: gebruik date() van Python en een domain op person_id.
👁 Toon oplossing
Python constraints worden omzeild door directe SQL-imports of bij bulk-operaties zonder ORM. SQL constraints zijn het vangnet: ze zitten in PostgreSQL zelf. Je definieert ze via _sql_constraints als klasseattribuut. Typisch gebruik: uniekheid van een serienummer per bedrijf, of een CHECK dat een getal positief is. Ze zijn sneller dan Python maar minder expressief.
class HorecaEquipment(models.Model): _name = 'horeca.equipment' _sql_constraints = [ ( 'unique_serial_per_company', 'UNIQUE(serial_no, company_id)', 'Serienummer moet uniek zijn per bedrijf.' ), ( 'positive_warranty_months', 'CHECK(warranty_months >= 0)', 'Garantiemaanden kunnen niet negatief zijn.' ), ]
Voeg een SQL constraint toe aan fsm.order uitbreiding die verhindert dat scheduled_date_end eerder ligt dan scheduled_date_start. Wat is het verschil met een Python constraint voor dit geval?
👁 Toon oplossing
@api.onchange wordt enkel in de UI getriggerd — nooit bij API-calls of imports. Je gebruikt het om velden automatisch voor te vullen of te waarschuwen, maar je mag er geen harde businessregels op baseren. In DispatchIQ: als de dispatcher een apparaat selecteert, vult refrigerant_type automatisch in op basis van het apparaatrecord.
@api.onchange('equipment_id') def _onchange_equipment_id(self): if self.equipment_id: self.refrigerant_type = self.equipment_id.refrigerant_type self.equipment_category = self.equipment_id.category_id # waarschuwing als garantie verlopen if self.equipment_id.warranty_end < fields.Date.today(): return {'warning': { 'title': 'Garantie verlopen', 'message': 'Dit toestel is buiten garantie. Informeer de klant voor aanvang.' }} else: self.refrigerant_type = False self.equipment_category = False
Schrijf een onchange op person_id (technieker) die de scheduled_date_start automatisch instelt op morgen 08:00 als die nog leeg is. Waarom mag je dit NIET als constraint schrijven?
👁 Toon oplossing
Computed fields en constraints moeten robuust zijn. De meest voorkomende valkuilen: een Many2one dat False is (niet ingevuld), een datumveld dat False is bij een nieuw record, of een getal dat 0.0 is in plaats van None. Gebruik altijd if rec.field_name: of if rec.field_name is not False: voordat je een relatie opzoekt.
# Veilige manier: altijd guarden op False-waarden @api.depends('equipment_id', 'equipment_id.last_service_date') def _compute_days_since_service(self): today = fields.Date.today() for order in self: if order.equipment_id and order.equipment_id.last_service_date: delta = today - order.equipment_id.last_service_date order.days_since_service = delta.days else: order.days_since_service = 0 # nooit False laten staan bij Float-veld
Wat gaat er fout in dit stukje code? order.days_overdue = (fields.Date.today() - order.deadline).days. Schrijf de gecorrigeerde versie.
👁 Toon oplossing
Een constraint is pas bewezen als je test dat hij effectief blokkeert. Schrijf negatieve tests: maak een record aan dat de regel schendt en verwacht een ValidationError. Gebruik self.assertRaises(ValidationError, ...). Test ook de grensgeval: precies op de grenswaarde mag het wel of net niet lukken.
from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase class TestOrderConstraints(TransactionCase): def test_negative_duration_blocked(self): with self.assertRaises(ValidationError): self.env['fsm.order'].create({ 'name': 'FSM/NEG/1', 'duration': -1.5, }) def test_zero_duration_allowed(self): # 0 is toegestaan: order nog niet gestart order = self.env['fsm.order'].create({ 'name': 'FSM/ZERO/1', 'duration': 0, }) self.assertEqual(order.duration, 0)
Schrijf een test voor de overlap-constraint uit 2.3: maak twee orders aan voor hetzelfde apparaat met overlappend tijdvenster en verwacht een ValidationError bij het aanmaken van de tweede.
👁 Toon oplossing
Elke compute met store=True en een lange dependency-keten kan leiden tot cascades: wijzig veld A → herbereken B → herbereken C → ... Voor DispatchIQ met duizenden orders kan dit de UI vertragen. Beperk dependency-ketens tot maximaal 2 niveaus. Gebruik depends_context enkel als het echt nodig is. Controleer via de Odoo developer mode "Dependencies" tab of er onverwachte recursieve herberekeningen zijn.
# SLECHT: cascade van 3 niveaus parts_total = fields.Float(compute='_compute_parts_total', store=True) is_high_value = fields.Boolean(compute='_compute_is_high_value', store=True) priority_score = fields.Integer(compute='_compute_priority', store=True) @api.depends('product_ids.price_unit', 'product_ids.qty_done') def _compute_parts_total(self): ... # OK: niveau 1 @api.depends('parts_total') # niveau 2 — nog OK def _compute_is_high_value(self): ... @api.depends('is_high_value', 'urgency') # niveau 3 — begin te vermijden def _compute_priority(self): ... # BETER: priority_score direct afhankelijk van parts_total @api.depends('parts_total', 'urgency') def _compute_priority(self): ...
Je merkt dat elke orderwijziging trage herberekeningen veroorzaakt. Welke stap onderneem je eerst om het probleem te diagnosticeren in de Odoo developer console?
👁 Toon oplossing
# Voorbeeld constraint op duur en serienummer @api.constrains('duration', 'serial_no') def _check_business_rules(self): for rec in self: if rec.duration and rec.duration < 0: raise ValidationError('Duur kan niet negatief zijn.') if rec.serial_no and len(rec.serial_no) < 5: raise ValidationError('Serienummer moet minstens 5 tekens hebben.')
Voeg constraints toe zodat een order geen negatieve duur kan hebben en een toestel-serienummer altijd uniek blijft binnen hetzelfde bedrijf.
XML-views bepalen hoe gebruikers met je data werken. Je leert nieuwe schermen bouwen en bestaande OCA-schermen uitbreiden via inheritance, zodat je updatesafe blijft zonder upstream code te forken.
Je werkt hier met UI-architectuur: velden, acties en context moeten exact aansluiten op dispatchtaken. Een technisch correcte view is pas geslaagd wanneer ze ook operationele fouten vermindert.
Een form view is de detailpagina van één record. De structuur volgt altijd hetzelfde patroon: <form> is de root, <sheet> bevat de inhoud, <group> rangschikt velden in kolommen, en <notebook> + <page> maken tabbladen. Voor horeca.equipment structureer je: basisinfo links (naam, serienummer, klant), technische info rechts (koelmiddel, vermogen), met tabbladen voor onderhoudsgeschiedenis en documenten.
<!-- views/horeca_equipment_views.xml --> <record id="view_horeca_equipment_form" model="ir.ui.view"> <field name="name">horeca.equipment.form</field> <field name="model">horeca.equipment</field> <field name="arch" type="xml"> <form string="Horeca Toestel"> <sheet> <group> <group string="Basisinfo"> <field name="name"/> <field name="serial_no"/> <field name="partner_id"/> </group> <group string="Technisch"> <field name="refrigerant_type"/> <field name="power_kw"/> <field name="warranty_end"/> </group> </group> <notebook> <page string="Onderhoud"> <field name="maintenance_ids"/> </page> </notebook> </sheet> </form> </field> </record>
Voeg een <div class="oe_title"> toe boven de <sheet> voor de naam van het toestel als grote koptekst. Hoe gebruik je <field name="name"> daarin?
👁 Toon oplossing
De list view (in Odoo XML: <tree>) toont records als rijen. Gebruik optional="show" op kolommen om ze standaard zichtbaar maar togglebaar te maken. Met sum="..." toon je kolomtotalen. default_order op het model of default_order attribuut op <tree> bepaalt de sortering. Voor horeca.equipment wil je naam, serienummer, klant, koelmiddel en garantiedatum — met optionele vermogenkolom.
<record id="view_horeca_equipment_tree" model="ir.ui.view"> <field name="model">horeca.equipment</field> <field name="arch" type="xml"> <tree string="Horeca Toestellen" default_order="name asc"> <field name="name"/> <field name="serial_no"/> <field name="partner_id"/> <field name="refrigerant_type"/> <field name="warranty_end"/> <field name="power_kw" optional="show" sum="Totaal kW"/> </tree> </field> </record>
Voeg een kleurcodering toe aan de list view: rijen met verlopen garantie (warranty_end < today) tonen rood. Hoe doe je dit in Odoo list views?
👁 Toon oplossing
De kanban view groepeert records als kaartjes per kolom. Voor fsm.order is dit de planningsbord: kolommen zijn stadia (Nieuw → Ingepland → In uitvoering → Afgerond). De kanban box template bepaalt wat er op elk kaartje staat. Gebruik t-att-class voor kleurcodering op basis van urgentie of garantiestatus.
<record id="view_fsm_order_kanban_horeca" model="ir.ui.view"> <field name="model">fsm.order</field> <field name="arch" type="xml"> <kanban default_group_by="stage_id"> <field name="name"/> <field name="person_id"/> <field name="partner_id"/> <field name="is_high_value"/> <templates> <t t-name="kanban-box"> <div t-att-class="'kanban-box' + (record.is_high_value.raw_value ? ' border-warning' : '')"> <strong><t t-esc="record.name.value"/></strong> <div><t t-esc="record.partner_id.value"/></div> <div><t t-esc="record.person_id.value"/></div> </div> </t> </templates> </kanban> </field> </record>
Voeg een avatar van de technieker toe op het kanban-kaartje. Gebruik <img t-att-src="..."/> met het Odoo image URL patroon.
👁 Toon oplossing
View inheritance laat je velden toevoegen aan een bestaande view zonder de originele module aan te passen. Je verwijst via inherit_id naar de bestaande view, en gebruikt <xpath> om een insertpunt te kiezen. De position bepaalt waar: after, before, inside, of replace. Gebruik altijd de meest stabiele xpath — liefst op name attribuut van een veld, niet op positie.
<record id="view_fsm_order_form_horeca" model="ir.ui.view"> <field name="name">fsm.order.form.horeca</field> <field name="model">fsm.order</field> <field name="inherit_id" ref="fieldservice.view_fsm_order_form"/> <field name="arch" type="xml"> <!-- na het bestaande 'person_id' veld --> <xpath expr="//field[@name='person_id']" position="after"> <field name="refrigerant_type"/> <field name="equipment_category"/> </xpath> <!-- nieuw tabblad in de notebook --> <xpath expr="//notebook" position="inside"> <page string="Horeca Details"> <field name="parts_total"/> <field name="is_high_value"/> </page> </xpath> </field> </record>
Wat is het risico van expr="//group[1]" als xpath? Schrijf een betere alternatieve selector.
👁 Toon oplossing
Het principe van niet-forken: je OCA-module blijft ongewijzigd en kan gewoon geüpdatet worden. Jouw dispatch_iq_horeca module voegt enkel velden en views toe via inheritance. Als OCA later iets wijzigt in hun form view, overleeft jouw xpath-extensie — zolang het ankerpunt stabiel blijft. Dit is de professionele aanpak voor Odoo-partners.
# models/fsm_order_extension.py class FsmOrderHorecaExtension(models.Model): _inherit = 'fsm.order' _description = 'FSM Order Horeca Uitbreiding' refrigerant_type = fields.Selection([ ('R32', 'R32'), ('R410A', 'R410A'), ('R290', 'R290'), ('R744', 'R744 (CO₂)'), ], string='Koelmiddel') equipment_category = fields.Selection([ ('combi_steamer', 'Combi-Steamer'), ('blast_chiller', 'Blast Chiller'), ('cold_room', 'Koelcel'), ('display_fridge', 'Display Koelkast'), ], string='Toestelcategorie') lez_zone = fields.Boolean( string='LEZ Zone', related='location_id.is_lez_zone', store=True, readonly=True )
Hoe weet je zeker dat je module geen OCA-bestand overschrijft? Welke mapstructuur bevestigt dat je een aparte module bent die inherit gebruikt?
👁 Toon oplossing
Menu items maak je aan via ir.ui.menu records in XML. Je kan zowel nieuwe toplevel menu's maken als submenu's hangen onder bestaande items. Voor DispatchIQ hang je Horeca-specifieke menu's onder het bestaande Field Service menu van OCA. Koppel altijd een ir.actions.act_window aan een menu item — anders crasht de klik.
<!-- Actie om het model te openen --> <record id="action_horeca_equipment" model="ir.actions.act_window"> <field name="name">Horeca Toestellen</field> <field name="res_model">horeca.equipment</field> <field name="view_mode">tree,form,kanban</field> </record> <!-- Submenu onder Field Service --> <menuitem id="menu_horeca_equipment" name="Horeca Toestellen" parent="fieldservice.menu_fieldservice_master" action="action_horeca_equipment" sequence="20"/>
Je wil het menu enkel zichtbaar maken voor gebruikers in de groep dispatch_iq_horeca.group_dispatcher. Welk attribuut voeg je toe aan <menuitem>?
👁 Toon oplossing
Via context op een act_window kan je default waarden meegeven aan nieuwe records. Via domain filter je welke records zichtbaar zijn. Smart buttons zijn kleine knoppen bovenaan een form view die gerelateerde records tellen en openen — standaard patroon in Odoo. Voor horeca.equipment: een smart button "Orders" dat toont hoeveel FSM-orders dit toestel al gehad heeft.
<!-- Smart button in form view --> <div class="oe_button_box" name="button_box"> <button name="action_view_fsm_orders" type="object" class="oe_stat_button" icon="fa-wrench"> <field name="fsm_order_count" widget="statinfo" string="Orders"/> </button> </div> # In het model: computed count + action fsm_order_count = fields.Integer(compute='_compute_fsm_order_count') def action_view_fsm_orders(self): return { 'type': 'ir.actions.act_window', 'res_model': 'fsm.order', 'domain': [('equipment_id', '=', self.id)], 'context': {'default_equipment_id': self.id}, 'view_mode': 'tree,form', }
Hoe schrijf je het domain om enkel orders in de stage "Afgerond" te tonen in het smart button venster?
👁 Toon oplossing
Met attrs (Odoo 16) of readonly/invisible attributen maak je velden conditioneel bewerkbaar. Dit is cruciaal voor DispatchIQ: een technieker mag het apparaattype niet meer wijzigen als de order al "In uitvoering" is. Gebruik states voor eenvoudige stage-gebaseerde logica, en attrs met domain voor complexere condities.
<!-- Odoo 16: attrs met domain --> <field name="equipment_id" attrs="{'readonly': [('stage_id.sequence', '>=', 3)]}"/> <field name="refrigerant_type" attrs="{'readonly': [('stage_id.is_closed', '=', True)], 'required': [('stage_id.sequence', '>=', 2)]}"/> <!-- Veld tonen enkel voor managers --> <field name="parts_cost_price" groups="dispatch_iq_horeca.group_manager"/>
Schrijf een attrs conditie die het veld cancellation_reason enkel zichtbaar maakt als de order gecancelled is (stage is_closed = True EN kanban_state = 'blocked').
👁 Toon oplossing
De grootste risicofactor bij OCA-updates is een gewijzigde upstream view die jouw xpath-selector breekt. De strategie: gebruik altijd semantische selectoren (op veldnaam, string-attribuut) in plaats van positionele (op index, op div-volgorde). Voeg ook een priority attribuut toe zodat jouw inherited view altijd na de base view geladen wordt. Test je views na elke OCA module update met --update=dispatch_iq_horeca.
<!-- FRAGIEL: positioneel --> <xpath expr="//div[2]/group[1]" position="after"> <!-- BREEKT bij upstream layout-wijziging --> <!-- ROBUUST: op veldnaam --> <xpath expr="//field[@name='person_id']" position="after"> <!-- ROBUUST: op string-attribuut --> <xpath expr="//page[@string='Extra Information']" position="inside"> <!-- Prioriteit: hogere waarde = later geladen --> <field name="priority">32</field> <!-- default is 16, OCA views zijn vaak 10-20 -->
Na een OCA update krijg je de fout "Element not found for xpath". Welke stappen onderneem je om het te debuggen en te fixen zonder de OCA module aan te passen?
👁 Toon oplossing
<xpath> uitbreiding op fsm.order form <record id="view_fsm_order_form_horeca" model="ir.ui.view"> <field name="inherit_id" ref="fieldservice.view_fsm_order_form"/> <field name="arch" type="xml"> <xpath expr="//notebook" position="inside"> <page string="Horeca"> <field name="refrigerant_type"/> <field name="equipment_category"/> </page> </xpath> </field> </record>
Extend het fsm.order form view: voeg een "Horeca" tabblad toe met refrigerant_type, equipment_category, en een smart button dat toont hoeveel orders dit apparaat al gehad heeft. Gebruik view inheritance — geen aanpassing van OCA bestanden.
Met QWeb breng je operationele data naar een juridisch bruikbare werkbon. Het doel is een PDF die techniekers en klanten meteen kunnen gebruiken, inclusief onderdelen, handtekening en totalen.
Naast layout werk je met juridische en operationele precisie: bedragen, btw en productregels moeten reproduceerbaar zijn. De PDF dient tegelijk als klantbewijs, intern werkdocument en auditbron.
QWeb is Odoo's XML-gebaseerde template engine. Het lijkt op Jinja2 maar werkt met XML-attributen in plaats van {% %} tags. De belangrijkste instructies: t-if/t-else voor conditionals, t-foreach/t-as voor loops, t-esc voor veilige HTML-escaped output, t-raw voor onge-escaped HTML, en t-field voor Odoo-veld rendering met widget-ondersteuning.
<!-- QWeb basispatroon voor werkbon --> <t t-foreach="docs" t-as="o"> <div class="page"> <!-- t-esc: auto HTML-escaped, veilig voor user input --> <h1><t t-esc="o.name"/></h1> <!-- t-field: widget-rendering (datum, monetair, ...) --> <p>Datum: <t t-field="o.scheduled_date_start"/></p> <!-- t-if: conditionale sectie --> <t t-if="o.signature"> <img t-att-src="'data:image/png;base64,' + o.signature.decode()"/> </t> <t t-else=""> <div class="signature-box">Handtekening klant:_________</div> </t> </div> </t>
Wat is het verschil tussen t-esc en t-raw? Wanneer gebruik je t-raw en waarom is dat risicovol?
👁 Toon oplossing
Een rapport registreer je via een ir.actions.report record. Dit koppelt een QWeb-template aan een model en maakt de "Afdrukken"-knop beschikbaar in de UI. Het binding_model_id veld zorgt dat het rapport automatisch verschijnt in het print-menu van dat model. Het report_name is de externe ID van je QWeb template.
<!-- reports/report_werkbon.xml --> <record id="action_report_fsm_werkbon" model="ir.actions.report"> <field name="name">Werkbon DispatchIQ</field> <field name="model">fsm.order</field> <field name="report_type">qweb-pdf</field> <field name="report_name">dispatch_iq_horeca.report_fsm_werkbon</field> <field name="report_file">dispatch_iq_horeca.report_fsm_werkbon</field> <field name="binding_model_id" ref="fieldservice.model_fsm_order"/> <field name="binding_type">report</field> </record> <!-- QWeb template --> <template id="report_fsm_werkbon"> <t t-call="web.html_container"> <t t-foreach="docs" t-as="o"> <t t-call="web.external_layout"> <div class="page"> <!-- werkbon inhoud hier --> </div> </t> </t> </t> </template>
Wat is het verschil tussen report_type="qweb-pdf" en report_type="qweb-html"? Wanneer gebruik je welke?
👁 Toon oplossing
De werkbon is het juridische document dat Jonas Vermeersch overlegt aan Frituur De Gouden Aardappel na een interventie. Het moet bedrijfsinformatie tonen (TechniCool NV, BTW-nr, logo), klantgegevens, het apparaat (combi-steamer, serienummer, koelmiddel R32), lijst van uitgevoerde werken, verbruikte onderdelen met prijs, BTW-berekening, en een handtekeningvak. Gebruik t-call="web.external_layout" voor het standaard Odoo-briefhoofd en -voettekst.
<div class="page"> <h2>Werkbon — <t t-esc="o.name"/></h2> <div class="row"> <div class="col-6"> <strong>Klant:</strong> <t t-esc="o.partner_id.name"/><br/> <t t-esc="o.location_id.street"/>, <t t-esc="o.location_id.city"/> </div> <div class="col-6"> <strong>Toestel:</strong> <t t-esc="o.equipment_id.name"/><br/> <strong>Serienr:</strong> <t t-esc="o.equipment_id.serial_no"/><br/> <strong>Koelmiddel:</strong> <t t-esc="o.refrigerant_type"/> </div> </div> <table class="table table-sm mt-4"> <thead><tr> <th>Onderdeel</th><th class="text-right">Qty</th> <th class="text-right">Prijs</th><th class="text-right">Totaal</th> </tr></thead> <tbody> <t t-foreach="o.product_ids" t-as="line"> <tr> <td><t t-esc="line.product_id.display_name"/></td> <td class="text-right"><t t-esc="line.qty_done"/></td> <td class="text-right"><t t-field="line.price_unit"/></td> <td class="text-right"><t t-esc="'%.2f' % (line.qty_done * line.price_unit)"/></td> </tr> </t> </tbody> </table> </div>
Voeg een subtotaal-, BTW- en totaalrij toe onderaan de onderdelen-tabel. BTW is 21%. Gebruik Python-expressies in t-esc.
👁 Toon oplossing
Odoo gebruikt wkhtmltopdf om QWeb HTML naar PDF te renderen. Dit is een headless WebKit browser — de PDF ziet er exact hetzelfde uit als de HTML in een browser. Maar let op: JavaScript werkt niet in wkhtmltopdf, externe fonts kunnen mislukken, en pagina-einden werken via CSS page-break-before/page-break-after. Headers en footers worden meegegeven via speciale QWeb templates.
<!-- CSS pagina-einde: start altijd op nieuwe pagina --> <div class="page" style="page-break-after: always;"> <!-- content pagina 1 --> </div> <!-- Header template --> <template id="report_fsm_werkbon_header"> <div class="header"> <span t-esc="company.name"/> — BTW: <span t-esc="company.vat"/> </div> </template> <!-- Paginanummer in footer --> <template id="report_fsm_werkbon_footer"> <div class="footer"> Pagina <span class="page"/> van <span class="topage"/> </div> </template>
De PDF-header toont niet het bedrijfslogo. Hoe controleer je of het een wkhtmltopdf-probleem is of een QWeb-templatefout? Noem 2 debugstappen.
👁 Toon oplossing
Jonas laat de klant tekenen op een tablet. De handtekening wordt opgeslagen als een Binary veld (fields.Binary) op fsm.order. In de UI gebruik je de signature widget. In de PDF render je de handtekening als base64-encoded PNG afbeelding via data:image/png;base64,...URI. Voeg ook een signature_date toe zodat het document juridisch dateerbaar is.
# model: Binary veld voor handtekening class FsmOrderExtension(models.Model): _inherit = 'fsm.order' signature = fields.Binary(string='Handtekening klant', attachment=True) signature_date = fields.Datetime(string='Ondertekend op', readonly=True) def write(self, vals): if 'signature' in vals and vals['signature']: vals['signature_date'] = fields.Datetime.now() return super().write(vals) <!-- XML view widget --> <field name="signature" widget="signature"/> <!-- QWeb: render als base64 img --> <t t-if="o.signature"> <img t-att-src="'data:image/png;base64,' + o.signature.decode()" style="height:80px; border-bottom: 1px solid #999;"/> <p>Ondertekend op: <t t-esc="o.signature_date"/></p> </t>
Hoe verhinder je dat de handtekening gewist wordt nadat een order "Afgerond" is? Schrijf de ORM-override.
👁 Toon oplossing
Na het afdrukken wil je de PDF bewaren als bijlage bij de order, zodat de dispatcher later kan nazoeken wat er aan de klant gegeven is. Dit doe je door de PDF-bytes op te slaan als ir.attachment record gekoppeld aan fsm.order. Je kan dit automatisch triggeren na het afsluiten van een order via een write() override of een base.automation.
def action_close_and_archive_pdf(self): """Sluit order af en slaat PDF op als bijlage.""" for order in self: # Genereer PDF bytes via Odoo rapport engine pdf_content, _ = self.env['ir.actions.report']._render_qweb_pdf( 'dispatch_iq_horeca.action_report_fsm_werkbon', [order.id] ) # Sla op als bijlage self.env['ir.attachment'].create({ 'name': 'Werkbon_%s.pdf' % order.name.replace('/', '_'), 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'fsm.order', 'res_id': order.id, 'mimetype': 'application/pdf', })
Schrijf een domain-query om alle PDF-bijlagen van een specifieke FSM-order op te halen via XML-RPC vanuit een externe Node.js applicatie.
👁 Toon oplossing
Als je de werkbontemplate aanpast (prijsberekening, lay-out), moeten historische werkbonnen nog steeds het oude format tonen. De aanpak: sla altijd de volledige PDF op als bijlage bij afsluiting (zie 4.6), én voeg een report_version veld toe aan de order zodat je weet welke template gebruikt werd. Combineer dit met een Char veld werkbon_template_version dat je vult op het moment van PDF-generatie.
# In het model werkbon_template_version = fields.Char( string='Werkbon template versie', default='1.0', readonly=True ) # Bij PDF-generatie altijd versie stampen CURRENT_WERKBON_VERSION = '2.1' def action_close_and_archive_pdf(self): for order in self: # ... PDF genereren ... order.write({ 'werkbon_template_version': CURRENT_WERKBON_VERSION }) # In de template: versie zichtbaar in footer <small>Werkbon v<t t-esc="o.werkbon_template_version"/> — <t t-esc="o.name"/></small>
Waarom is het onvoldoende om gewoon de QWeb-template op te slaan in git voor "historische" werkbonnen? Wat is het echte probleem?
👁 Toon oplossing
Een werkbon met 50+ onderdeelregels kan wkhtmltopdf laten crashen of Odoo een timeout geven. Aanpak: begrens het aantal regels per pagina via CSS page-break-inside: avoid op tabelrijen, vermijd het fetchen van te veel related records in de template, en preload alle benodigde data in een _get_report_values methode op het model. Gebruik ook sudo() doordacht — te brede rechten vertragen de PDF-render.
# Preload data in Python, niet in QWeb template def _get_werkbon_data(self): """Bereid alle data voor in één query.""" self.ensure_one() lines = self.product_ids.read([ 'product_id', 'qty_done', 'price_unit' ]) partner = self.partner_id.read(['name', 'street', 'city', 'vat'])[0] return { 'order': self, 'lines': lines, 'partner': partner, 'total': sum(l['qty_done'] * l['price_unit'] for l in lines), } /* CSS: verbreek tabelrijen niet halverwege een pagina */ tr { page-break-inside: avoid; }
De PDF-generatie duurt 8 seconden voor orders met veel onderdelen. Welke Odoo-configuratieparameter kan je aanpassen om de wkhtmltopdf timeout te verhogen, en waar staat die?
👁 Toon oplossing
<t t-foreach="docs" t-as="o"> werkbon snippet <h2><t t-esc="o.name"/></h2> <p>Klant: <t t-esc="o.partner_id.name"/></p> <p>Toestel: <t t-esc="o.equipment_id.name"/></p> <table> <t t-foreach="o.product_ids" t-as="line"> <tr> <td><t t-esc="line.product_id.display_name"/></td> <td><t t-esc="line.quantity"/></td> </tr> </t> </table>
Bouw de DispatchIQ Werkbon PDF: professioneel layout met bedrijfslogo, klantgegevens, apparaatdetails (merk/serienr/koelmiddel), lijst van uitgevoerde werken, verbruikte onderdelen met eenheidsprijzen, subtotaal ex BTW, BTW 21%, totaal incl. BTW, handtekeningvak. Knop "Afdrukken" op fsm.order form.
Wizards maken complexe dispatcher-acties veilig en snel. Je bouwt tijdelijke flow-schermen die skills-checks en bulkacties begeleiden, zonder gebruikers rechtstreeks aan gevoelige onderliggende modellen te laten sleutelen.
Je bouwt wizards als gecontroleerde beslisflow: elke stap valideert invoer en bewaart context. Daardoor krijgen dispatchers snelheid zonder datamodel te compromitteren met ad-hoc handelingen.
De dispatch.skill model is een catalogus van technische competenties: F-gasattest, HACCP-kennis, combi-steamer kalibratie, blast chiller diagnose, LEZ-voertuig rijbewijs. Elke skill heeft een categorie (koeltechniek, hygiëne, elektriciteit) en een geldigheidsperiode in maanden. Dit model is de referentietabel waaruit techniekers en orders kunnen putten.
# models/dispatch_skill.py class DispatchSkill(models.Model): _name = 'dispatch.skill' _description = 'Technische Competentie' _order = 'category, name' name = fields.Char(string='Skill', required=True) category = fields.Selection([ ('refrigeration', 'Koeltechniek'), ('hygiene', 'Hygiëne & HACCP'), ('electrical', 'Elektriciteit'), ('driving', 'Rijbewijs & LEZ'), ], required=True) validity_months = fields.Integer( string='Geldigheid (maanden)', default=24 ) requires_certificate = fields.Boolean( string='Certificaat verplicht' ) description = fields.Text(string='Beschrijving')
Maak demo-data XML aan met 3 skills: F-gas attest (koeltechniek, 36 maanden, certificaat verplicht), HACCP basisopleiding (hygiëne, 12 maanden), en LEZ-rijbewijs (rijbewijs, 60 maanden).
👁 Toon oplossing
De koppeling tussen techniekers (fsm.person) en skills zit in een junction model dispatch.person.skill. Dit model registreert niet enkel welke skills Jonas heeft, maar ook zijn niveau (beginner/gevorderd/expert), de datum van zijn certificaat, en wanneer de skill vervalt. Een computed veld is_valid berekent of de skill nog geldig is op vandaag.
class DispatchPersonSkill(models.Model): _name = 'dispatch.person.skill' _description = 'Technieker Competentie' person_id = fields.Many2one('fsm.person', required=True, ondelete='cascade') skill_id = fields.Many2one('dispatch.skill', required=True) level = fields.Selection([ ('beginner', 'Beginner'), ('advanced', 'Gevorderd'), ('expert', 'Expert'), ], default='beginner') certificate_date = fields.Date(string='Certificaatdatum') expiry_date = fields.Date( string='Vervaldatum', compute='_compute_expiry_date', store=True ) is_valid = fields.Boolean( string='Geldig', compute='_compute_is_valid' ) @api.depends('certificate_date', 'skill_id.validity_months') def _compute_expiry_date(self): from dateutil.relativedelta import relativedelta for rec in self: if rec.certificate_date and rec.skill_id.validity_months: rec.expiry_date = rec.certificate_date + relativedelta( months=rec.skill_id.validity_months ) else: rec.expiry_date = False
Schrijf de _compute_is_valid methode: is_valid is True als de vervaldatum in de toekomst ligt OF als er geen vervaldatum is (permanente skill).
👁 Toon oplossing
Een models.TransientModel (ook gekend als wizard) slaat tijdelijke data op die na de actie niet bewaard hoeft te worden. In DispatchIQ gebruik je een wizard voor de techniekertoewijzing: de dispatcher kiest een technieker, de wizard toont ontbrekende skills, en pas na bevestiging wordt de order geüpdatet. Transient records worden automatisch opgeruimd door Odoo (standaard na 1 uur).
# wizards/dispatch_assign_wizard.py class DispatchAssignWizard(models.TransientModel): _name = 'dispatch.assign.wizard' _description = 'Wizard: Technieker Toewijzen' order_id = fields.Many2one('fsm.order', required=True) technician_id = fields.Many2one('fsm.person', required=True) missing_skills = fields.Text( string='Ontbrekende skills', compute='_compute_missing_skills' ) override_reason = fields.Text(string='Reden voor override') @api.depends('technician_id', 'order_id') def _compute_missing_skills(self): for wizard in self: if wizard.technician_id and wizard.order_id: missing = wizard.order_id.check_technician_skills( wizard.technician_id.id ) wizard.missing_skills = ', '.join(missing) if missing else '' else: wizard.missing_skills = ''
Wanneer is een TransientModel NIET de juiste keuze? Geef een concreet DispatchIQ-voorbeeld waarbij je beter een gewoon Model gebruikt.
👁 Toon oplossing
Dispatchers willen soms meerdere orders in één keer herplannen (bijv. bij ziekte van Jonas). Een bulk-wizard opent vanuit de list view na selectie van meerdere orders. De wizard toont hoeveel orders geselecteerd zijn, vraagt een nieuwe technieker en een reden, en werkt alle geselecteerde orders bij in één transactie. De context active_ids en active_model geven de wizard toegang tot de geselecteerde records.
class DispatchBulkRescheduleWizard(models.TransientModel): _name = 'dispatch.bulk.reschedule.wizard' order_ids = fields.Many2many( 'fsm.order', default=lambda self: self.env.context.get('active_ids', []) ) new_person_id = fields.Many2one('fsm.person', required=True) reschedule_reason = fields.Text(required=True) order_count = fields.Integer( compute='_compute_order_count', string='Aantal orders' ) def action_confirm_reschedule(self): for order in self.order_ids: order.person_id = self.new_person_id.id order.message_post(body='Herplanning: %s' % self.reschedule_reason) return {'type': 'ir.actions.act_window_close'}
Hoe koppel je deze bulk-wizard aan een knop in de list view van fsm.order? Schrijf de XML actiedefinitie.
👁 Toon oplossing
In een wizard valideer je expliciet in de action_confirm methode, niet alleen via @api.constrains. Dit geeft je meer controle over de foutmelding en de wizard blijft open zodat de gebruiker kan corrigeren. De flow: valideer → als fout, raise ValidationError → wizard blijft open met foutbericht → gebruiker corrigeert → bevestigt opnieuw.
def action_confirm(self): """Valideer en wijs toe. Raise ValidationError bij problemen.""" # 1. Skills check missing = self.order_id.check_technician_skills(self.technician_id.id) if missing and not self.override_reason: raise ValidationError( 'Technieker %s mist skills: %s.\n' 'Geef een override-reden als je toch wil doorgaan.' % (self.technician_id.name, ', '.join(missing)) ) # 2. Beschikbaarheid check if not self.technician_id.active: raise ValidationError('Technieker %s is inactief.' % self.technician_id.name) # 3. Alles OK: wijs toe self.order_id.person_id = self.technician_id.id if missing: self.order_id.message_post( body='⚠ Override: %s. Ontbrekende skills: %s' % (self.override_reason, ', '.join(missing)) ) return {'type': 'ir.actions.act_window_close'}
Hoe toon je in de wizard UI realtime feedback "⚠ 2 ontbrekende skills" terwijl de dispatcher nog aan het selecteren is, zonder dat hij eerst op bevestigen klikt?
👁 Toon oplossing
De wizard moet snel bereikbaar zijn vanuit de dagelijkse dispatcher-workflow. Op de fsm.order form view voeg je een knop "Technieker toewijzen" toe die de wizard opent als popup (target: 'new'). Vanuit de list view werkt het via binding zoals in 5.4. De knop is enkel zichtbaar in de juiste stage (nog niet "In uitvoering").
# Python: methode die de wizard opent def action_open_assign_wizard(self): return { 'type': 'ir.actions.act_window', 'name': 'Technieker Toewijzen', 'res_model': 'dispatch.assign.wizard', 'view_mode': 'form', 'target': 'new', # popup 'context': {'default_order_id': self.id}, } <!-- XML: knop op form view, enkel zichtbaar in juiste stage --> <button name="action_open_assign_wizard" string="Technieker Toewijzen" type="object" class="btn-primary" icon="fa-user-plus" attrs="{'invisible': [('stage_id.sequence', '>=', 3)]}"/>
Na het sluiten van de wizard wil je dat de form view automatisch ververst (om de nieuwe technieker te tonen). Hoe doe je dat via de return waarde van action_confirm?
👁 Toon oplossing
Als de gewenste technieker niet beschikbaar is, suggereert de wizard alternatieve techniekers die alle vereiste skills voor de order hebben en geen overlappende orders hebben op het geplande tijdstip. Je sorteert ze op afstand tot de locatie (vereenvoudigd: op postcode-match) en skills-completeness. Dit is het begin van een intelligente dispatchfunctie.
def _get_suggested_technicians(self): """Geef geschikte alternatieve techniekers terug.""" order = self.order_id required_skills = order.required_skill_ids.mapped('skill_id') # Alle actieve techniekers candidates = self.env['fsm.person'].search([ ('active', '=', True), ('id', '!=', order.person_id.id), ]) result = [] for tech in candidates: tech_skills = tech.skill_ids.filtered('is_valid').mapped('skill_id') missing = required_skills - tech_skills if not missing: # geen ontbrekende skills # check geen overlap overlap = self.env['fsm.order'].search_count([ ('person_id', '=', tech.id), ('scheduled_date_start', '<', order.scheduled_date_end), ('scheduled_date_end', '>', order.scheduled_date_start), ]) if not overlap: result.append(tech.id) return result
Hoe toon je de gesuggereerde techniekers in de wizard UI als een Many2many readonly veld? Schrijf het model-veld en de wizard XML.
👁 Toon oplossing
Elke dispatching-beslissing moet traceerbaar zijn. Was er een skill-override? Wie bevestigde het? Wanneer? Dit log je op twee niveaus: in de order chatter via message_post() (zichtbaar voor dispatchers), en in een audit-model dispatch.assignment.log (voor management rapportages). De combinatie geeft zowel operationeel inzicht als compliance-bewijs.
class DispatchAssignmentLog(models.Model): _name = 'dispatch.assignment.log' _description = 'Toewijzingsaudit' _order = 'create_date desc' order_id = fields.Many2one('fsm.order', ondelete='cascade') assigned_by = fields.Many2one('res.users', default=lambda s: s.env.uid) technician_id = fields.Many2one('fsm.person') had_skill_override = fields.Boolean() override_reason = fields.Text() missing_skills_at_time = fields.Text() # In wizard action_confirm: self.env['dispatch.assignment.log'].create({ 'order_id': self.order_id.id, 'technician_id': self.technician_id.id, 'had_skill_override': bool(missing), 'override_reason': self.override_reason, 'missing_skills_at_time': ', '.join(missing), })
Schrijf een Odoo domain voor een list view die enkel de logs toont waarbij een skill-override plaatsvond de afgelopen 30 dagen. Hoe gebruik je dit als management dashboard?
👁 Toon oplossing
# transient wizard model class DispatchAssignWizard(models.TransientModel): _name = 'dispatch.assign.wizard' order_id = fields.Many2one('fsm.order', required=True) technician_id = fields.Many2one('fsm.person', required=True) def action_confirm(self): missing = self.order_id.check_technician_skills(self.technician_id.id) if missing: raise ValidationError('Ontbrekende skills: %s' % ', '.join(missing)) self.order_id.person_id = self.technician_id.id
Bouw een wizard die bij het toewijzen van een order onmiddellijk controleert op ontbrekende skills en alternatieve techniekers voorstelt.
Je sluit af met security en testdiscipline. Toegangsregels beschermen productiegegevens, en tests bewijzen dat je module stabiel blijft bij toekomstige wijzigingen. Dit is het verschil tussen demo-code en professionele oplevering.
De focus verschuift naar releasekwaliteit: je test niet alleen happy paths, maar ook misbruik en regressie. Zo kan de module veilig mee in CI/CD zonder verborgen datalekken of permission bypasses.
Het bestand security/ir.model.access.csv bepaalt welke groepen welke CRUD-acties mogen uitvoeren op elk model. Zonder een regel in dit bestand kan niemand het model openen — ook niet de admin (tenzij superuser). De kolommen zijn: id, name, model_id/id, group_id/id, perm_read, perm_write, perm_create, perm_unlink. Geef alleen wat strikt nodig is: dispatchers kunnen geen records verwijderen.
# security/ir.model.access.csv
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_horeca_equipment_dispatcher,horeca.equipment dispatcher,model_horeca_equipment,dispatch_iq_horeca.group_dispatcher,1,1,1,0
access_horeca_equipment_technician,horeca.equipment technician,model_horeca_equipment,dispatch_iq_horeca.group_technician,1,0,0,0
access_horeca_equipment_manager,horeca.equipment manager,model_horeca_equipment,dispatch_iq_horeca.group_manager,1,1,1,1
access_dispatch_skill_all,dispatch.skill all,model_dispatch_skill,base.group_user,1,0,0,0
access_dispatch_person_skill_dispatcher,dispatch.person.skill dispatcher,model_dispatch_person_skill,dispatch_iq_horeca.group_dispatcher,1,1,1,0
access_dispatch_assignment_log_manager,dispatch.assignment.log manager,model_dispatch_assignment_log,dispatch_iq_horeca.group_manager,1,0,0,0
Na installatie krijg je "Access Denied" op dispatch.assign.wizard. Wat ontbreekt er en hoe fix je het?
👁 Toon oplossing
Record rules (ir.rule) voegen een automatisch filter toe op elk search() en read() voor een bepaalde groep. Voor DispatchIQ: een technieker mag enkel orders zien waar hij de person_id is. Dispatchers zien alles. Record rules zijn cumulatief: als meerdere regels van toepassing zijn, worden ze gecombineerd met OR (binnen dezelfde groep) of AND (tussen groepen). Gebruik global=True enkel voor regels die voor iedereen gelden.
<!-- security/record_rules.xml --> <odoo> <record id="rule_fsm_order_technician" model="ir.rule"> <field name="name">FSM Order: technieker ziet enkel eigen orders</field> <field name="model_id" ref="fieldservice.model_fsm_order"/> <field name="groups" eval="[(4, ref('dispatch_iq_horeca.group_technician'))]"/> <field name="domain_force">[('person_id.partner_id.user_ids', 'in', [user.id])]</field> <field name="perm_read" eval="True"/> <field name="perm_write" eval="True"/> <field name="perm_create" eval="False"/> <field name="perm_unlink" eval="False"/> </record> </odoo>
Schrijf een record rule die verhindert dat een dispatcher orders ziet van een ander bedrijf in een multi-company setup. Welk Odoo veld gebruik je in het domain?
👁 Toon oplossing
Security tests zijn negative tests: je test wat gebruikers niet mogen doen. Schrijf voor elke rol minstens één test die bevestigt dat verboden acties een AccessError geven. Test ook cross-user scenario's: kan Jonas de orders van een andere technieker zien? Kan een dispatcher een record rule omzeilen via XML-RPC? Gebruik with_user() om als een specifieke gebruiker te zoeken.
from odoo.exceptions import AccessError from odoo.tests.common import TransactionCase class TestSecurityRoles(TransactionCase): def setUp(self): super().setUp() group_tech = self.env.ref('dispatch_iq_horeca.group_technician') self.jonas = self.env['res.users'].create({ 'name': 'Jonas Test', 'login': 'jonas_test', 'groups_id': [(4, group_tech.id)] }) self.order_other = self.env['fsm.order'].create({ 'name': 'FSM/OTHER/1' # geen person_id = Jonas }) def test_technician_cannot_see_other_orders(self): orders = self.env['fsm.order'].with_user(self.jonas).search([]) order_ids = orders.ids self.assertNotIn(self.order_other.id, order_ids) def test_technician_cannot_delete(self): with self.assertRaises(AccessError): self.order_other.with_user(self.jonas).unlink()
Schrijf een test die bevestigt dat een dispatcher de parts_cost_price kan schrijven, maar een technieker niet (het veld is alleen zichtbaar voor managers via groups= in de view, maar de modelregel verhindert schrijven).
👁 Toon oplossing
TransactionCase is de standaard testklasse in Odoo. Elke test draait in een aparte transactie die na de test volledig wordt teruggedraaid — daardoor zijn tests geïsoleerd en herlaadbaarbaar. Gebruik setUp() voor gedeelde testdata, en schrijf per methode precies één scenario. Goede tests zijn snel (<1s), deterministisch, en falen duidelijk.
from odoo.tests.common import TransactionCase from odoo.exceptions import ValidationError class TestFsmOrderBusiness(TransactionCase): def setUp(self): super().setUp() self.equipment = self.env['horeca.equipment'].create({ 'name': 'Combi-Steamer RX', 'serial_no': 'CS-001-TEST', 'refrigerant_type': 'R32', }) def test_parts_total_computed_correctly(self): order = self.env['fsm.order'].create({'name': 'FSM/CT/1'}) # voeg productregels toe en check computed total self.assertEqual(order.parts_total, 0.0) def test_duplicate_serial_blocked(self): with self.assertRaises(Exception): # IntegrityError of ValidationError self.env['horeca.equipment'].create({ 'name': 'Ander toestel', 'serial_no': 'CS-001-TEST', # dubbel! })
Hoe draai je alleen de tests van jouw module zonder alle andere Odoo tests? Geef het CLI commando.
👁 Toon oplossing
HttpCase tests draaien een echte HTTP-client tegen je Odoo instance. Je kan hiermee controleren dat views laden zonder 500-fouten, dat rapportacties correct renderen, of dat een tour (stap-voor-stap UI flow) slaagt. Ze zijn trager dan TransactionCase maar testen de volledige stack. Gebruik ze spaarzaam: enkel voor kritieke flows zoals de werkbon PDF download of de wizard-koppeling.
from odoo.tests.common import HttpCase class TestWerkbonReport(HttpCase): def test_werkbon_pdf_renders(self): # Maak een testorder aan order = self.env['fsm.order'].create({'name': 'FSM/PDF/1'}) # Vraag de PDF op via HTTP response = self.url_open( '/report/pdf/dispatch_iq_horeca.action_report_fsm_werkbon/%d' % order.id ) self.assertEqual(response.status_code, 200) self.assertEqual(response.headers['Content-Type'], 'application/pdf') self.assertGreater(len(response.content), 1000) # niet leeg def test_tour_assign_technician(self): self.start_tour( "/web#action=fieldservice.action_fsm_order", "dispatch_assign_tour", login="admin" )
Wat is het nadeel van HttpCase tests ten opzichte van TransactionCase, en wanneer kies je toch voor HttpCase?
👁 Toon oplossing
Een CI pipeline draait automatisch je tests bij elke push naar GitHub of GitLab. Voor Odoo modules gebruik je typisch een Docker-gebaseerde pipeline: start een Odoo + PostgreSQL container, installeer je module, run de tests, rapporteer het resultaat. GitHub Actions met de officiële Odoo Docker image is de meest toegankelijke aanpak voor studenten.
# .github/workflows/test.yml name: Test dispatch_iq_horeca on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo POSTGRES_DB: odoo_test steps: - uses: actions/checkout@v3 - name: Run Odoo tests run: | docker run --network host \ -e DB_HOST=localhost -e DB_USER=odoo -e DB_PASSWORD=odoo \ -v $(pwd):/mnt/extra-addons \ odoo:16 \ odoo --test-enable \ --test-tags=dispatch_iq_horeca \ -d odoo_test \ -i dispatch_iq_horeca \ --stop-after-init
Hoe zorg je dat de CI pipeline faalt bij één mislukte test (niet alleen bij een crash)? Welke Odoo exit code gebruik je als criteria?
👁 Toon oplossing
Een security smoke suite is een vaste set van snelle tests die je draait voor elke release. Ze controleren de meest kritieke scenario's: kan een technieker zichzelf tot manager promoveren? Kan een dispatcher data exporteren naar CSV zonder toestemming? Staat de _inherits keten geen onbedoelde toegang toe? Deze tests zijn klein maar hoog-impact — ze voorkomen de echt pijnlijke security incidenten.
class TestSecuritySmoke(TransactionCase): def test_technician_cannot_escalate_to_manager(self): group_tech = self.env.ref('dispatch_iq_horeca.group_technician') group_mgr = self.env.ref('dispatch_iq_horeca.group_manager') tech_user = self.env['res.users'].create({ 'name': 'Jonas', 'login': 'j_smoke', 'groups_id': [(4, group_tech.id)] }) with self.assertRaises(AccessError): tech_user.with_user(tech_user).write({ 'groups_id': [(4, group_mgr.id)] }) def test_dispatch_log_not_accessible_by_technician(self): group_tech = self.env.ref('dispatch_iq_horeca.group_technician') tech_user = self.env['res.users'].create({ 'name': 'Jonas2', 'login': 'j_smoke2', 'groups_id': [(4, group_tech.id)] }) with self.assertRaises(AccessError): self.env['dispatch.assignment.log'].with_user(tech_user).search([])
Voeg een smoke test toe die controleert dat de REST-export endpoint (/web/dataset/call_kw) geen dispatch.assignment.log records teruggeeft voor een technieker-gebruiker.
👁 Toon oplossing
Een professionele release van dispatch_iq_horeca volgt een vaste checklist. De versie in __manifest__.py volgt semantic versioning (MAJOR.MINOR.PATCH). Migratiescripts in migrations/X.Y.Z/ updaten bestaande data. Een rollbackplan beschrijft hoe je de vorige versie herstelt als de deployment mislukt. Dit is het verschil tussen een demo-module en een productieklare levering.
# __manifest__.py versie bump { 'name': 'DispatchIQ Horeca', 'version': '16.0.1.2.0', # Odoo versie + MAJOR.MINOR.PATCH 'depends': ['fieldservice', 'fieldservice_stock'], } # migrations/16.0.1.2.0/pre-migrate.py def migrate(cr, version): """Rename column refrigerant naar refrigerant_type.""" cr.execute(""" ALTER TABLE fsm_order RENAME COLUMN refrigerant TO refrigerant_type """) # Rollback procedure (README.md sectie) # 1. Stop Odoo # 2. git checkout v1.1.0 (vorige versie) # 3. pg_restore backup_pre_deploy.dump → herstel DB # 4. ./odoo-bin -u dispatch_iq_horeca --stop-after-init # 5. Start Odoo
Je voegt een nieuw verplicht veld toe aan horeca.equipment. Wat moet je in het migratiescript doen zodat bestaande records niet breken na de update?
👁 Toon oplossing
# tests/test_skill_check.py class TestSkillCheck(TransactionCase): def test_missing_skill_blocks_assignment(self): order = self.env['fsm.order'].create({'name': 'FSM/TST/1'}) tech = self.env['fsm.person'].create({'name': 'Jonas'}) missing = order.check_technician_skills(tech.id) self.assertTrue(len(missing) > 0)
De module dispatch_iq_horeca is volledig: horeca.equipment model, fsm.order uitbreiding, werkbon PDF, skills model met technieker-skill koppeling, security rules. Module installeert foutloos, heeft 5+ unit tests groen, en is gepubliceerd op GitHub. Mondeling: leg de view inheritance uit met xpath.
DispatchIQ
Het Eindproject
Geen werkend eindproject = niet geslaagd. Geen uitzonderingen. Dit is geen schoolopdracht — dit is een product dat in productie draait bij een echte horeca servicebedrijf.
- Sidebar met open orders gesorteerd per type
- Filters op type, regio, prioriteit, apparaattype
- Gantt tijdlijn 07:00–19:00
- Technici als rijen met thuislocatie
- Drag van sidebar naar Gantt
- Reistijdblok automatisch ingevoegd (ORS)
- Pauzeblok 12u, verplaatsbaar
- Constraint waarschuwingen bij drop
- Installeerbaar op Android/iOS
- Lijst van eigen orders vandaag
- "Aankomst" knop → timer start
- Checklist per apparaattype invullen
- Onderdelen verbruik registreren
- Handtekening veld
- Foto uploaden als bijlage
- Offline capable (service worker)
- OdooClient met volledige CRUD
- API logger naar JSON
- WebSocket server voor live updates
- ORS matrix calls voor reistijd
- Constraint check endpoints
- IDispatchAdapter interface
- Auth middleware (JWT)
- OCA fieldservice volledig geconfigureerd
- Technici, voertuigen, stock ingesteld
- dispatch_iq_horeca module geïnstalleerd
- Werkbon PDF per order genereerbaar
- Skills & certificaten per technieker
- LEZ zone tags op voertuigen
- Site access flags op locaties
- Parts check (stock.quant vs voertuig)
- Skills check (dispatch.technician.skill)
- Site access check
- LEZ/rijzone check
- Dubbele boeking blokkeren
- Smart suggest alternatief
- Docker Compose volledige stack
- Traefik HTTPS automatisch
- OpenRouteService met Belgium OSM
- GitHub repo met README
- Deployment script (1 commando)
- Backup strategie voor Odoo DB
| Onderdeel | Gewicht | Buis bij |
|---|---|---|
| Werkende applicatie in productie HTTPS, Odoo connectie, Gantt met echte data, constraints live |
35% | Minder dan 50% = niet geslaagd |
| Mondeling — code begrip Elke lijn uitleggen, aanpassen, debuggen. Vragen over keuzes. |
25% | Minder dan 10/20 = niet geslaagd |
| Odoo module kwaliteit dispatch_iq_horeca: proper ORM, tests, security, PDF werkbon |
20% | — |
| Code kwaliteit & documentatie README, inline comments, TypeScript types, Git history |
10% | — |
| Extra features & creativiteit ORS reistijdoptimalisatie, Navision adapter, AI skill suggest, ... |
10% | — |
| Totaal | 100% | Min 60% geslaagd, tenzij buis |
Sprint 1
Sprint 2
🔴 BUIS 1
Sprint 4
🔴 BUIS 2
Sprint 6
Verdediging
| Gate | Minimum bewijs | No-Go bij |
|---|---|---|
| Gate A — Productie bereikbaar Uiterlijk week 18 |
HTTPS actief, login werkt, health endpoint /api/health geeft 200 |
Geen publiek bereikbare omgeving |
| Gate B — End-to-end dispatchflow Uiterlijk week 24 |
Order zichtbaar in Gantt, drag/drop schrijft naar Odoo, update verschijnt live in tweede sessie | Alleen demo met mock data |
| Gate C — Constraint veiligheid Uiterlijk week 30 |
Minstens 3 constraints blokkeren foutieve planning met duidelijke reden + alternatief | Constraint waarschuwing zonder echte blokkering |
| Gate D — Operationale oplevering Week 36 verdediging |
PWA flow, werkbon PDF, audit logs, herstelprocedure en README volledig aantoonbaar | Geen herstel- of testbewijs bij incidentvraag |
- Publieke productie-URL met geldige HTTPS
- Werkende demo zonder lokaal gefoefel of hardcoded mocks
- Tweede sessie/tab voor live sync demonstratie
- Mobiele PWA demo op echt toestel
- GitHub repo met heldere README en architectuurdiagram
- Testresultaten van frontend, backend en Odoo module
- Voorbeeld van audit log, backup en restore run
- Commitgeschiedenis die incrementeel werk aantoont
- Waarom gekozen voor deze dataflow en niet een andere
- Uitleg van 1 constraint van UI tot databasebeslissing
- Debug van een defect tijdens de verdediging
- Trade-offs: performance, security en maintainability
| Scenario | Stap | Verwacht resultaat |
|---|---|---|
| T1 — Dubbele boeking | Sleep order op technieker met overlap | Drop wordt geweigerd, reden bevat overlap, bestaande planning blijft ongewijzigd |
| T2 — Parts tekort | Plan order met ontbrekend onderdeel | Constraint dialoog toont ontbrekende parts met qty_needed/qty_available |
| T3 — Skills mismatch | Kies technieker zonder vereist certificaat | Toewijzing blokkeert en toont minstens 1 alternatieve technieker |
| T4 — Live sync | Wijzig planning in tab A | Tab B krijgt update binnen 2 seconden zonder refresh |
| T5 — PWA offline | Technieker registreert interventie zonder netwerk | Actie queue't lokaal en synchroniseert automatisch bij reconnect |
| T6 — Audit trail | Wijzig technieker, status en tijdslot van één order | Logs bevatten trace-id, actor, oude/nieuwe waarde en timestamp |
| T7 — Recovery drill | Herstel DB backup op staging | Applicatie draait opnieuw met consistente orders en attachments |
- CI groen: unit tests + smoke tests + lint
- DB backup uitgevoerd en restore getest op staging
- Release notes met schema/API wijzigingen klaar
- Feature flags gezet voor risicovolle flows
- Rollback image tag vooraf beschikbaar
docker compose pull && docker compose up -d- Migraties uitvoeren en output loggen
- Health checks valideren: frontend/api/odoo/ors
- Rooktest: order openen, plannen, terugschrijven
- Latency check op top 3 API endpoints
- 30 min verhoogde monitoring op errors en queue backlog
- Bij incident: freeze nieuwe deploys en activeer rollback
- Communicatie-template naar gebruikers binnen 15 min
- RCA document binnen 48u met preventieve acties
- Update runbook en testmatrix na elk incident