Hogeschool Programma · Academiejaar 2025–2026

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.

36
Weken
288u
Contacturen
5
Vakken
1
Eindproject
Slaagvoorwaarde: Het eindproject DispatchIQ moet in productie draaien, gekoppeld aan Odoo, met werkende constraints engine en Gantt planning. Gedeeltelijke realisatie = niet geslaagd. Vakdeelcijfers tellen mee voor het gemiddelde maar compenseren niet voor een niet-werkend eindproject.
AI Tools beleid: Claude Code, GitHub Copilot, Gemini — allemaal toegestaan. Vereiste: je kan élke lijn code uitleggen, aanpassen en debuggen. Examenvraag "leg deze functie uit" = mondeling. Kan je het niet uitleggen → 0 voor dat onderdeel.
Instapniveau & randvoorwaarden
Wat vooraf in orde moet zijn
GitHub account met werkende SSH key en vaste repo-structuur
Lokale devomgeving: Node.js, pnpm/npm, Docker, VS Code of Cursor
Servertoegang: SSH login, domein, reverse proxy en HTTPS basiskennis
Werkritme: minimum 4u zelfstandige bouwtijd per week naast contacturen
Logboek per week: blockers, beslissingen, screenshots en links naar commits
Demo-discipline: elke vrijdag een toonbaar increment, hoe klein ook
Belangrijk: deze track is projectgedreven. Wie pas bouwt vlak voor een buismoment, komt te laat. Elke week moet resulteren in code, testbewijs en een korte demo.
Didactische werking
Vast weekritme
Blok 1
Concept + live demo
Nieuwe theorie wordt meteen gekoppeld aan DispatchIQ code of Odoo configuratie, niet als los hoofdstuk.
90 min
Blok 2
Begeleid bouwen
Je zet de theorie direct om in code, met debugging, logging en testbewijs als verplichte output.
120 min
Blok 3
Code review + mondeling
Je verdedigt keuzes: waarom deze component, endpoint, ORM-relatie of constraint. AI-output zonder begrip valt hier door de mand.
45 min
Blok 4
Zelfstandig sprintwerk
Je sluit de week af met commitlinks, open issues, next step en bewijs dat de increment draait op je omgeving.
Resttijd
Jaarplanning op hoofdlijnen
Semester structuur
S1
Fundamenten (Weken 1–18)
Frontend · Backend/API · Odoo Basis // 18 weken × 8u = 144u
Wk 1–6
Vak 1: Frontend Development
HTML/CSS/JS fundament, React, component thinking, eerste Gantt mockup
48u
Wk 7–12
Vak 2: API & Backend
Node.js, Express, REST, XML-RPC, Docker, PostgreSQL, API proxy logger
48u
Wk 13–18
Vak 3: Odoo Basis
Odoo installeren, datamodel begrijpen, eerste API calls, OCA fieldservice
48u
Wk 18
🔴 Milestone 1 — Buis check
Werkende Gantt UI + Odoo XML-RPC connectie + data zichtbaar in frontend. Haalt dit niet → bijkomende taken zomer.
BUISLIJN
S2
Integratie & Expertise (Weken 19–36)
OCA · Addon Dev · Eindproject // 18 weken × 8u = 144u
Wk 19–24
Vak 4: OCA & Configuratie
OCA fieldservice modules, stock, agreements, custom fields via interface
48u
Wk 25–30
Vak 5: Odoo Addon Development
Python, XML views, ORM, custom module, horeca equipment addon
48u
Wk 30
🔴 Milestone 2 — Constraints live
Parts check, skills check, LEZ zone check werkend in DispatchIQ. Terugschrijven naar Odoo werkend.
BUISLIJN
Wk 31–36
Eindproject afronden + verdediging
OpenRouteService integratie, live technician view, documentatie, code review
VERDEDIGING
Reeds aanwezig — jouw voorsprong
Wat je al hebt
Hetzner server operationeel
Docker + Traefik + Portainer werkend
Claude Code + Gemini CLI geïnstalleerd
Basis Linux terminal kennis
Next.js project (Bossuyt app) al gestart
Begrip van horeca domein (jouw sterkste troef)
Omdat je al een serveromgeving hebt en Next.js kent, sla je de allereerste stappen van frontend en Docker over. Je start frontendvak dus al in week 2 bij React components. De gewonnen tijd gaat naar diepere Odoo kennis.
Begeleiding & bijsturing
Feedbackloop en herstelpad
Wekelijks
Sprint review
Je toont live wat werkt, wat geblokkeerd is en welke technische schuld je bewust laat staan tot de volgende sprint.
Review
Na elk vak
Retake via gerichte gap-fix
Geen volledige herhaling van het vak: je herwerkt enkel het ontbrekende deel tot het voldoet aan de definition of done.
Fix
Na buismoment
Herstelopdracht met deadline
Je krijgt een beperkte set extra taken met harde datum. Doel: terug op de kritieke pad van het eindproject komen.
Herstel
Doorlopend
Bewijsmap
Per sprint bewaar je demo-video, README update, testresultaten, screenshots en belangrijkste commit-hashes. Dat dossier telt mee in de mondelinge verdediging.
Bewijs
Vak 1 · Weken 1–6 · 48 contacturen

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.

6
Weken
48u
Les
1
Deliverable
● Vak 1 — Frontend Development
Leerdoelstellingen
Na dit vak kan je:
HTML/CSS schrijven zonder framework als fallback
JavaScript DOM manipulatie begrijpen
React components bouwen met hooks
State management met useState/useReducer
Drag & drop implementeren zonder library
API calls doen vanuit frontend (fetch/axios)
Tailwind CSS gebruiken voor responsive layout
Een custom Gantt chart renderen
PWA basis configureren (manifest, service worker)
AI tools gebruiken én de output doorgronden
Cursusinhoud
Hoofdstukken
H1
Het Web Begrijpen — HTTP, DOM & JavaScript
// hoe een browser werkt van URL tot pixel
Week 1 · 8u

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.

1.1 HTTP request/response cyclus

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 })
})
✏ Mini-oefening

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
1. Druk F12 → klik op "Network" tab → ververs de pagina (F5). 2. Klik op het eerste request in de lijst (meestal de HTML pagina zelf). 3. Je ziet rechts: Method = GET, Status = 200. 4. Klik op "Headers" subtab → scroll naar "Response Headers" → zoek 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.
1.2 De DOM — pagina manipuleren met JavaScript

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
});
✏ Mini-oefening

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
Na 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.
1.3 JavaScript essentials — de syntax die je dagelijks gebruikt

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
✏ Mini-oefening

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.
1.4 Fetch API & async/await — data ophalen van een server

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 [];
  }
}
✏ Mini-oefening

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
Output in de console: 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.
1.5 Browser DevTools — je debugsuperkracht

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.

✏ Mini-oefening

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
Bij Reddit zie je typisch requests naar 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.
1.6 Rendering pipeline — waarom soms alles schokt

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.

✏ Mini-oefening

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
In de Performance timeline zie je gekleurde blokken: - 🟡 Geel = JavaScript uitvoering (Scripting) - 🟣 Paars = Layout (positieberekeningen) - 🟢 Groen = Paint (pixels tekenen) - 🔵 Blauw = HTML parsen (Loading) Een paars blok langer dan ~16ms (= 1 frame bij 60fps) betekent dat de browser een frame overslaat → zichtbare schokken in animaties. In de Gantt van DispatchIQ: als je 50 orderblokken tegelijk versleept en de browser doet telkens een volledige layout herberekening, wordt de animatie hakkelig. Oplossing: gebruik transform: translateX() in plaats van left: — transforms bypassen layout en paint.
1.7 Web performance basis — snelle apps op trage verbindingen

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.

✏ Mini-oefening

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
Op Slow 3G (~400 Kbps) laadt een gemiddelde website 8-15 seconden. Een zware site met veel JavaScript kan 30+ seconden duren. Het grootste bestand is bijna altijd een JavaScript bundle (eindigend op .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);
  }
}
🏆 Challenge H1 Integreert alles

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.
H2
React — Component Thinking
// van UI naar herbruikbare bouwblokken
Week 2 · 8u

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.

2.1 Waarom React? — Declaratief programmeren

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.

✏ Mini-oefening

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
Het juiste antwoord is B. De echte DOM is traag. De Virtual DOM is een lichtgewicht kopie in het geheugen. React vergelijkt de nieuwe Virtual DOM met de oude (diffing) en voert alleen de strikt noodzakelijke wijzigingen uit op de echte DOM (reconciliatie).
2.2 JSX — HTML in JavaScript (maar dan anders)

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>
  );
};
✏ Mini-oefening

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).
2.3 Props & State — De motor van je component

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>
  );
}
✏ Mini-oefening

In DispatchIQ: is de 'lijst van alle technici' die uit de API komt State of Props voor het hoofdmenu?

👁 Toon oplossing
Meestal State in het bovenliggende component (dat de data ophaalt) en Props voor de specifieke lijst-componenten die deze data doorgestuurd krijgen. Data flowt altijd naar beneden (Unidirectional Data Flow).
2.4 Lijsten & Keys — Performant renderen

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} 
  />
))}
✏ Mini-oefening

Waarom is het gebruiken van de array-index (0, 1, 2...) als key een slecht idee als de lijst gesorteerd kan worden?

👁 Toon oplossing
Als je sorteert, krijgt een ander item index 0. React denkt dan dat het oude item 0 gewoon gewijzigd is in plaats van verplaatst. Dit veroorzaakt bugs in input-velden en animaties. Gebruik altijd een stabiele id.
2.5 useEffect — Praten met de buitenwereld

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
✏ Mini-oefening

Wat gebeurt er als je de dependency array volledig weglaat bij useEffect?

👁 Toon oplossing
Het effect draait dan bij elke render. Als je in dat effect de state update, krijg je een infinite loop: render -> effect -> state update -> render -> effect... Je app crasht direct.
2.6 Custom Hooks — Logica extraheren

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();
2.7 Composition & Children — Flexibele layouts

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>
2.8 Component Design — Clean Code principes

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>
  );
};
⚙ Praktijkopdracht H2

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.

H3
State Management & Data Flow
// complexe state, context, reducers
Week 3 · 8u

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.

3.1 Lifting State Up — Data delen tussen buren

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.

✏ Mini-oefening

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
Het juiste antwoord is C. Door de state 'omhoog te tillen' naar de container, kun je de ID als prop naar beide kinderen sturen.
3.2 useReducer — De State Machine

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 });
3.3 Context API — Weg met prop-drilling

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);
3.4 Immutability — Nooit direct aanpassen!

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]);
3.5 Optimistic Updates — De illusie van snelheid

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'.

✏ Mini-oefening

Wat moet je opslaan vóórdat je een optimistic update uitvoert om een rollback mogelijk te maken?

👁 Toon oplossing
Een snapshot van de huidige state. Als de API-call een error geeft, zet je de state simpelweg terug naar deze snapshot.
3.6 State Normalization — Slimme data-structuren

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.

3.7 Zustand — Lichtgewicht Global State

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] })),
}));
3.8 Conflict Resolution — Als twee mensen tegelijk plannen

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;
  }
}
⚙ Praktijkopdracht H3

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.

H4
Custom Gantt Component — Tijdas & Rendering
// tijd naar pixels, rijstructuur, pointer fundamenten
Week 4 · 8u

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.

4.1 Tijdsas naar pixel — De wiskunde van de planner

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
✏ Mini-oefening

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
400px. Uitleg: Het midden van de dag is 50% (of 4 uur van de 8). 50% van 800px is 400px. In code: (4/8) * 800 = 400.
4.2 Pixel naar tijd — De omgekeerde wereld

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.

4.3 Rijenmodel — Absolute positionering

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.

4.4 Uurmarkeringen — Het visuele grid

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.

4.5 Pauzeblokken — De heilige middagrust

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.

4.6 Render Performance — useMemo & React.memo

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.

4.7 Timezone Discipline — Nooit meer 2 uur ernaast

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.

✏ Mini-oefening

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
08:00. Je trekt 2 uur af van de lokale tijd om de "wereldtijd" (UTC) te krijgen.
4.8 Visual Accessibility — Kleur is niet genoeg

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);
	};
⚙ Praktijkopdracht H4

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.

H5
Drag & Drop Volledig
// drop zones, snap op 15 min, collision checks
Week 5 · 8u

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.

5.1 DragStart & Payload — Wat neem je mee?

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.

5.2 Drop Target Detectie — Waar landt de order?

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.

5.3 Snapping — Altijd netjes op het kwartier

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;
5.4 Collision Detection — Geen dubbele boekingen

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'.

✏ Mini-oefening

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
JA. Order B start voordat Order A gedaan is (10:45 < 11:00). In code: (startB < endA) && (endB > startA).
5.5 Pointer Events — Van desktop naar tablet

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.

5.6 Optimistic UI & Rollback — Direct resultaat

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.

5.7 Constraint Preview — Zie fouten vóór je dropt

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'.

5.8 Undo/Redo — Fouten herstellen

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;
});
Pointer Events API werkt op touch én muis tegelijk — één implementatie voor desktop dispatcher en mobile technieker. Gebruik setPointerCapture() zodat events blijven binnenkomen ook als de vinger/cursor buiten het element gaat.
⚙ Praktijkopdracht H5

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.

H6
Next.js, Tailwind & PWA
// production deployment, routing, offline capable
Week 6 · 8u

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.

6.1 Next.js App Router — De nieuwe standaard

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.

6.2 Server vs Client Components — Waar draait je code?

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.

✏ Mini-oefening

Een component dat alleen de 'naam van het bedrijf' uit de database toont: Server of Client component?

👁 Toon oplossing
Server Component. Omdat er geen interactiviteit (knoppen, state, hooks) nodig is, kan Next.js dit direct op de server renderen en als pure HTML naar de browser sturen. Dit is sneller.
6.3 API Routes — Je eigen backend-in-frontend

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.

6.4 Tailwind CSS — Razendsnel stylen

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.

6.5 PWA & Offline — Werken in de kelder

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.

6.6 Docker Deployment — Naar de Hetzner server

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).

6.7 Offline Sync — Data inhalen

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).

6.8 Environment Variables — Veiligheid voorop

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"
}
⚙ Praktijkopdracht H6 (Deliverable Vak 1)

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.

Vak 2 · Weken 7–12 · 48 contacturen

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.

● Vak 2 — API & Backend Development
Leerdoelstellingen
Na dit vak kan je:
Een REST API bouwen met Node.js en Express
Middleware schrijven (auth, logging, error handling)
PostgreSQL queries schrijven en een schema ontwerpen
Docker Compose configureren voor multi-service omgeving
XML-RPC calls doen naar Odoo
Een API proxy logger bouwen die calls opslaat
Authenticatie implementeren (JWT, sessies)
WebSockets gebruiken voor real-time updates
OpenRouteService API aanroepen voor reistijd
Cursusinhoud
Hoofdstukken
H1
Node.js & Express Fundament
// hoe werkt een webserver?
Week 7 · 8u

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.

1.1 Node.js Event Loop — De kracht van non-blocking

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.

✏ Mini-oefening

Wat gebeurt er als je een zware berekening (bijv. 10 seconden lang getallen optellen) uitvoert in de hoofddraad van Node.js?

👁 Toon oplossing
De server blokkeert volledig. Geen enkele andere gebruiker kan meer een request doen totdat de berekening klaar is. Daarom doen we zware taken in Node.js altijd asynchroon of in aparte worker threads.
1.2 Express Setup — Routes & Parameters

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' });
});
1.3 Middleware — Het filter-systeem

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.

1.4 REST Principes — Semantisch web

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'.

1.5 Postman — Je API testen

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.

1.6 Environment Variables — Veiligheid met .env

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.

✏ Mini-oefening

Waarom moet je .env toevoegen aan je .gitignore bestand?

👁 Toon oplossing
Om te voorkomen dat je per ongeluk je wachtwoorden en API-keys naar GitHub pusht. Als je dit vergeet, kan iedereen ter wereld in jouw database of Odoo-omgeving inloggen.
1.7 API Conventions — Structuur in de chaos

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.

1.8 Input Validatie — Vertrouw de gebruiker nooit

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 });
});
⚙ Praktijkopdracht H1

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.

H2
PostgreSQL & Drizzle ORM
// data persistent opslaan en ophalen
Week 8 · 8u

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.

2.1 PostgreSQL — De robuuste database

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.

✏ Mini-oefening

Wat is het verschil tussen een Primary Key en een Foreign Key?

👁 Toon oplossing
De Primary Key is de unieke ID van een rij in zijn eigen tabel (bijv. Order #5). De Foreign Key is diezelfde ID, maar dan gebruikt in een andere tabel om een link te leggen (bijv. de order_id kolom in de Tabel 'Onderdelen').
2.2 Pure SQL — Praten met de data

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;
2.3 Drizzle ORM — Type-safe queries

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!

2.4 Het DispatchIQ Schema — Architectuur

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.

2.5 Migrations — De tijdlijn van je database

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.

✏ Mini-oefening

Waarom zou je nooit handmatig tabellen aanpassen via een interface (zoals DBeaver) op je productie-server?

👁 Toon oplossing
Omdat je code dan 'out of sync' raakt met de database. Als je een nieuwe versie van je app deployt die een kolom verwacht die er niet is (of anders heet), crasht je app direct. Migrations automatiseren dit proces veilig.
2.6 Transactions — Alles of niets

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.

2.7 Indexering — Snelheid voorop

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.

2.8 pgvector — AI in je database

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'),
});
⚙ Praktijkopdracht H2

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.

H3
Docker Compose & Multi-Service
// productie-omgeving op je server
Week 9 · 8u

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.

3.1 Docker recap — Containers vs Images

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.

3.2 Docker Compose — De dirigent

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.

✏ Mini-oefening

Als je container 'api' wil praten met container 'db', welk IP-adres moet de api dan gebruiken?

👁 Toon oplossing
In een Docker network heb je geen IP-adres nodig. Je gebruikt gewoon de servicenaam als hostnaam (dus: db). Docker regelt de rest via interne DNS.
3.3 De DispatchIQ Stack — Architectuur

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.

3.4 Traefik & HTTPS — Productierijp

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!

3.5 Health Checks — Wachten op de database

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.

✏ Mini-oefening

Wat is het gevaar van een API die probeert te verbinden met een database die nog aan het opstarten is?

👁 Toon oplossing
De API zal direct crashen met een 'Connection Refused' error. Als je geen goede restart-policy hebt, blijft je hele app offline. Health checks lossen dit op door de API pas 'los te laten' als de DB echt antwoordt.
3.6 Secrets Management — Nooit meer plain text

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.

3.7 Resource Limits — Noisy Neighbors

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.

3.8 Disaster Recovery — Backups

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"
⚙ Praktijkopdracht H3

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.

H4
Odoo XML-RPC Integratie
// authentiseren, search_read, write op fsm.order
Week 10 · 8u

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.

4.1 XML-RPC — De taal van Odoo

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.

4.2 De Context Parameter — Taal en tijdzone

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.

✏ Mini-oefening

Waarom is de tz (timezone) parameter in de context zo belangrijk voor een plannings-app?

👁 Toon oplossing
Odoo slaat alles intern op in UTC. Als je een order plant om 09:00 lokale tijd zonder tijdzone-context, kan Odoo denken dat het 09:00 UTC is, waardoor de technieker in de zomer plots 2 uur te laat (of te vroeg) komt.
4.3 Magic Tuples — Werken met relaties

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
};
4.4 Domain Syntax — Slim filteren

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".

4.5 Search_read — Efficiënt data ophalen

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.

✏ Mini-oefening

Wat is het gevaar van een API call zonder limit of fields parameter naar een grote Odoo database?

👁 Toon oplossing
De server kan proberen tienduizenden records met honderden kolommen (inclusief zware afbeeldingen/PDF's) in één keer over het netwerk te sturen. Dit leidt tot een Timeout of een Memory Error in je Node.js backend.
4.6 Foutafhandeling — Odoo Exceptions

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").

4.7 Model Inspectie — Fields_get

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.

4.8 Sudo & Rechten — Veiligheid

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' }
  });
}
Tijdzone valkuil: Odoo slaat datums op als UTC in de database. Als je 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.
⚙ Praktijkopdracht H4

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.

H5
API Proxy Logger
// middleware logging, trace-id, caching per endpoint
Week 11 · 8u

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.

5.1 Proxy Pattern — De tussenman

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.

5.2 Winston Logging — Zwart-op-wit

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
});
5.3 Request Interception — Data omvormen

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'.

5.4 Response Transformation — Alleen het nodige

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.

✏ Mini-oefening

Wat is het voordeel van het hernoemen van velden zoals x_studio_technician_id naar simpelweg techId in je proxy?

👁 Toon oplossing
Je frontend wordt onafhankelijk van de technische namen in Odoo. Als je ooit een ander veld in Odoo gaat gebruiken, hoef je alleen je proxy aan te passen, en niet je hele frontend-codebase. Dit noemen we Decoupling.
5.5 Rate Limiting — De server beschermen

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.

5.6 Correlation IDs — De rode draad

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.

5.7 Performance Metrics — Meten is weten

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.

5.8 Sensitive Data Masking — Privacy

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();
});
⚙ Praktijkopdracht H5

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.

H6
WebSockets & OpenRouteService
// real-time + reistijdberekening
Week 12 · 8u

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.

6.1 WebSockets — Real-time planning

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.

6.2 Socket.io Events — Praten over de lijn

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'
});
6.3 OpenRouteService — Routeberekening

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.

6.4 Matrix API — Wie is het dichtstbij?

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)".

✏ Mini-oefening

Waarom gebruiken we reistijd in minuten in plaats van afstand in kilometers voor de planning?

👁 Toon oplossing
Omdat 10km in de stad (file) veel langer duurt dan 10km op de autosnelweg. Een planner plant in tijd, niet in afstand. 20 minuten reistijd is 20 minuten minder werktijd bij de klant.
6.5 Geocoding — Adres naar coördinaten

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.

6.6 Geofencing — Automatische statusupdates

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.

6.7 Real-time GPS Tracking

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.

6.8 Pub/Sub Architectuur — Schaalbaarheid

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;
}
⚙ Praktijkopdracht H6 (Deliverable Vak 2)

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.

Vak 3 · Weken 13–18 · 48 contacturen

Odoo
Basis

Odoo als businesssoftware begrijpen. Datamodel kennen. Field Service workflow van klant tot factuur. Dit is de fundament voor Vak 4 en 5.

● Vak 3 — Odoo Basis
Leerdoelstellingen
Na dit vak kan je:
Odoo 17 installeren via Docker
De complete field service workflow configureren
Klanten, locaties, apparatuur, technici aanmaken
Het ORM datamodel verklaren (models, fields, relations)
Stock en voertuigbeheer instellen
Een factuur genereren vanuit een werkorder
Rapportages aanpassen via Odoo interface
Gebruikers, rollen en rechten beheren
Cursusinhoud
Hoofdstukken
H1
Odoo Installatie & Oriëntatie
// community vs enterprise, modules, eerste configuratie
Week 13 · 8u

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.

1.1 Odoo Editions — Welke kies je?

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.

✏ Mini-oefening

Welke versie van Odoo is het meest geschikt voor een start-up die volledige controle wil over de broncode zonder licentiekosten?

👁 Toon oplossing
De Community versie (in combinatie met OCA modules). Je bent dan eigenaar van je eigen data en infrastructuur, en je betaalt geen jaarlijkse kosten per gebruiker aan Odoo S.A.
1.2 Docker Setup — Je eigen Odoo server

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.

1.3 Modules & Dependencies

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.

1.4 Bedrijfsinstellingen — TechniCool NV

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.

1.5 Navigatie & Filters — Vind je weg

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.

1.6 Gebruikers & Rollen

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.

1.7 Backup Discipline — Geen dataverlies

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.

✏ Mini-oefening

Bevat een standaard Postgres SQL-dump ook de foto's die techniekers hebben geüpload naar Odoo?

👁 Toon oplossing
Nee. Foto's en PDF's worden opgeslagen in de Filestore op de harde schijf (in de Docker volume). Een volledige Odoo backup (via de interface) neemt beide mee, maar een pure SQL dump niet. Onthoud dit voor je backup-strategie!
1.8 Troubleshooting — Als Odoo niet start

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
⚙ Praktijkopdracht H1

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.

H2
Odoo Datamodel — Het fundament
// models, fields, relations, ORM begrijpen
Week 14 · 8u

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.

2.1 Models & Fields — De bouwstenen

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.

2.2 res.partner — Het hart van de data

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.

2.3 Relational Fields — Connecties leggen

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.

✏ Mini-oefening

Welk veldtype gebruik je om aan een Technicus een lijst van 5 verschillende Skills (zoals 'Electriciteit', 'Loodgieter') te koppelen?

👁 Toon oplossing
Een Many2many veld. Eén technicus heeft vele skills, en één skill (bijv. 'Loodgieter') wordt gedeeld door vele technici.
2.4 Product & Stock — Fysieke items

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).

2.5 Developer Mode — Onder de motorkap

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.

2.6 Inheritance — Bouwen op anderen

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.

2.7 Domain Syntax — Slim filteren

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.

2.8 Database Mapping — Van Odoo naar Postgres

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);
});
⚙ Praktijkopdracht H2

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.

H3
res.partner, product & stock
// kernmodellen die DispatchIQ elke dag gebruikt
Week 15 · 8u

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.

3.1 res.partner — Bedrijven vs Personen

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.

3.2 Locaties & Equipment — Waar staat het?

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.

✏ Mini-oefening

Waarom koppelen we een order aan een fsm.location en niet direct aan een res.partner?

👁 Toon oplossing
Omdat één klant (de partner die de factuur betaalt) honderden verschillende locaties kan hebben. Door op locatie-niveau te werken, weet de technieker exact naar welk adres hij moet rijden zonder de facturatie-data te vervuilen.
3.3 Order Types & Stages — De workflow

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.

3.4 Technici & Voertuigen

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.

3.5 De Volledige Flow — Van belletje tot geld

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.

3.6 Stock Moves — Het spoor van data

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.

3.7 Prioriteit & SLA — Tijd is geld

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.

3.8 Data Quality — Vertrouw je data

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
⚙ Praktijkopdracht H3

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.

H4
Field Service workflow
// van orderaanmaak tot factuur in fsm.order
Week 16 · 8u

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.

4.1 Order Types — De flow bepalen

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.

4.2 Stages — De levensloop van een order

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").

✏ Mini-oefening

Welke stage-overgang is het meest cruciaal voor de dispatcher om te weten dat hij een nieuwe taak kan toewijzen?

👁 Toon oplossing
De overgang van Ter Plaatse naar Gedaan (of Afgewerkt). Op dat moment weet de dispatcher dat de technieker weer vrij is en zijn voertuig mogelijk weer onderdelen moet laden.
4.3 Planningvelden — Tijd en Mens

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.

4.4 Onderdelenregistratie — Wat is verbruikt?

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.

4.5 Werkbondata — Het bewijs

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.

4.6 Facturatie — Van uren naar euro's

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.

4.7 Stage Validaties — Foutloos werken

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.

✏ Mini-oefening

Waarom is het beter om de validatie in Odoo te doen in plaats van alleen in de DispatchIQ frontend?

👁 Toon oplossing
De database is de Single Source of Truth. Als iemand de order aanpast via de Odoo interface (en niet via jouw app), moet de regel nog steeds gelden. Validatie in de backend is de enige manier om data-integriteit 100% te garanderen.
4.8 Automatische Activiteiten

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')
⚙ Praktijkopdracht H4

Configureer stages en ordertypes zodat een techniekerorder van open naar facturatie loopt zonder handmatig data over te typen tussen modules.

H5
Stock per voertuig
// fleet en locatiehiërarchie voor onderdelen
Week 17 · 8u

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.

5.1 Stock locaties — magazijn, voertuig en klant

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/MagazijnVEH/Bestelwagen-JonasKlant/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'])
✏ Mini-oefening

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
1. Voorraad → Configuratie → Locaties → Nieuw 2. Naam: 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.
5.2 Voertuig als stock locatie — fleet.vehicle koppelen

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']}
)
✏ Mini-oefening

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
1. Vloot → Voertuigen → Jonas zijn bestelwagen → tabblad "Voorraad" → veld Stock Locatie = WH/Stock/Jonas 2. Voorraad → Operaties → Fysieke inventarisatie → Nieuw 3. Locatie = WH/Stock/Jonas, Product = Compressorventiel R32, Geteld aantal = 5 → Valideer 4. XML-RPC check: search_read('stock.quant', [['location_id.complete_name','=','WH/Stock/Jonas']], ['product_id','quantity']) → geeft [{'product_id': [42, 'Compressorventiel R32'], 'quantity': 5.0}]
5.3 Min/max regels — automatische aanvulling

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]]
)
✏ Mini-oefening

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
1. Voorraad → Configuratie → Aanvulregels → Nieuw → vul product, locatie, min=2, max=8 in 2. Verlaag stock: Operaties → Aanpassen → stel in op 1 → Valideer 3. Scheduler: Voorraad → Operaties → Aanvullen (of via Configuratie → Technisch → Geplande acties → "Minimum Stock Rules") 4. Odoo maakt een interne transfer aan: WH/Stock → WH/Stock/Jonas, qty = 7 (aanvullen tot max 8) 5. Je vindt de transfer onder Voorraad → Operaties → Transfers, filter op type "Internal"
5.4 Fleet module — voertuigen, rijzones en tags

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')]
✏ Mini-oefening

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
1. Vloot → Configuratie → Tags → Nieuw: "LEZ-Antwerpen" en "Diesel-Euro6" 2. Vloot → Voertuigen → Jonas → tabblad Info → Tags: voeg beide toe 3. XML-RPC check: 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)
5.5 Groepen en rechten — ir.model.access en record rules

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
✏ Mini-oefening

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
1. Instellingen → Gebruikers → Nieuw gebruiker Jonas, groep = Inventory / User 2. Log in als Jonas → Voorraad → Producten → Actuele voorraad 3. Als record rules correct zijn: Jonas ziet enkel quants op locaties gekoppeld aan zijn voertuig 4. Als admin zie je alle quants van alle locaties 5. Als Jonas meer ziet dan verwacht: controleer of de record rule actief is en het domein klopt (domein mag geen syntax fouten bevatten, anders negeert Odoo de rule stiekem)
5.6 Audit trail — chatter, mail.message en tracking

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])
✏ Mini-oefening

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
1. Field Service → Orders → kies een order → klik "Bevestigen" 2. Scroll naar chatter onderaan: je ziet "Stage gewijzigd: Concept → Bevestigd" met gebruiker en tijdstip 3. Via XML-RPC: 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'])
5.7 Cycle counting — periodieke voertuigtelling

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]
)
✏ Mini-oefening

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
1. Voorraad → Operaties → Fysieke inventarisatie → filter Locatie = WH/Stock/Jonas 2. Klik op de rij van het product → zet "Geteld aantal" op 3 (was 5) 3. Klik "Alle toepassen" of het vinkje op die rij 4. Odoo maakt automatisch een correctie-move aan: -2 van WH/Stock/Jonas naar Virtual/Inventory Loss 5. In de chatter van het quant: "Voorraad gecorrigeerd: 5.0 → 3.0 door [jouw naam] op [datum]" 6. Deze move is te zien via Voorraad → Rapporten → Bewegingen met filter Locatie = WH/Stock/Jonas
5.8 Dead stock analyse — zelden gebruikte onderdelen

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]
✏ Mini-oefening

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
Pas de cutoff aan naar 30 dagen en run het script. In een echte deployment: 1. Stuur het rapport automatisch elke maandagmorgen naar de dispatcher 2. Dispatcher beslist per product: terugsturen naar magazijn of drempel verlagen 3. Interne transfer aanmaken: vehicle_loc → WH/Stock voor de dead stock producten 4. Min/max regel verwijderen of qty_min op 0 zetten zodat het niet meer automatisch aangevuld wordt Koppeling met DispatchIQ: voeg een endpoint toe 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);
}
⚙ Praktijkopdracht H5

Bouw voertuigstock met min/max regels per bestelwagen en toon welke onderdelen automatisch moeten worden aangevuld voor de volgende dag.

H6
Rechten & audit
// ir.model.access, record rules, chatter controls
Week 18 · 8u

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.

6.1 Access CSV — CRUD rechten per model per groep

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
✏ Mini-oefening

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
1. Maak security/groups.xml aan: <record id="group_technician" model="res.groups"> <field name="name">Technieker</field> <field name="category_id" ref="base.module_category_field_service"/> </record> 2. Voeg toe aan ir.model.access.csv: access_location_tech,location_tech,fieldservice.model_fsm_location,group_technician,1,0,0,0 3. Update manifest 'data' lijst zodat groups.xml voor ir.model.access.csv staat. 4. Installeer: odoo -u dispatch_iq_horeca 5. Check: Instellingen → Technisch → Beveiliging → Toegangsrechten → filter op "fsm.location"
6.2 Record rules — technieker ziet enkel eigen orders

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>
✏ Mini-oefening

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
Met record rule actief: Jonas ziet enkel zijn eigen order (1 record). Zonder record rule: Jonas ziet beide orders. Verificatie via XML-RPC als Jonas zijn gebruiker: models_as_jonas = xmlrpc.client.ServerProxy(url + '/object') count = models_as_jonas.execute_kw(db, jonas_uid, jonas_pw, 'fsm.order', 'search_count', [[]]) # → geeft 1 (enkel zijn eigen order) Als admin: count = models_as_admin.execute_kw(db, admin_uid, admin_pw, 'fsm.order', 'search_count', [[]]) # → geeft 2 (admins zijn vrijgesteld van record rules)
6.3 Chatter logging — auditspoor per order

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'
)
✏ Mini-oefening

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
1. Wijzig state van 'active' → 'defect' via de UI 2. Haal het mail.message op: msgs = search_read('mail.message', [['res_id','=', equip_id],['model','=','horeca.equipment'], ['tracking_value_ids','!=',False]], ['tracking_value_ids','date']) 3. Haal tracking values op: tvs = search_read('mail.tracking.value', [['id','in', msgs[0]['tracking_value_ids']]], ['field','old_value_char','new_value_char']) # → {'field': 'state', 'old_value_char': 'Actief', 'new_value_char': 'Defect'}
6.4 Activity tracking — taken en deadlines per rol

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]
])
✏ Mini-oefening

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
1. Python: order.activity_schedule('mail.mail_activity_data_todo', date_deadline=..., user_id=...) 2. UI: open de order → je ziet een gele klokbel bovenaan → klik erop 3. XML-RPC: search_read('mail.activity', [['res_id','=',order_id],['res_model','=','fsm.order']], ['summary','date_deadline','user_id']) 4. Markeer als gedaan: klik de ✓ knop op de activiteitskaart 5. Na markering: in de chatter verschijnt "Activiteit gedaan: [samenvatting]" als automatische notitie
6.5 Test met 3 rollen — dispatcher, technieker, admin

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'}
      )
✏ Mini-oefening

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
1. Log in als Jonas (dispatcher-rol ontbreekt) 2. Field Service → Orders → Nieuw: je krijgt "Toegang geweigerd" popup — perm_create=0 werkt 3. Pieter zijn order URL: /web#model=fsm.order&id=X&view_type=form → Odoo geeft lege pagina of "Record niet gevonden" — record rule werkt 4. Als het NIET werkt: controleer of je record rule correct aan group_technician hangt (groups eval moet de juiste ref bevatten) 5. Controleer ook of Jonas zijn res.users.partner gekoppeld is aan een fsm.person record
6.6 Compliance check — gevoelige velden afschermen

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
✏ Mini-oefening

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
Als Jonas via XML-RPC search_read doet met 'maintenance_price' in fields: → Odoo retourneert {'maintenance_price': False} — niet 0.0 maar False Dit is het kenmerk: afgeschermde float/int velden geven False terug, niet 0. Via de UI: het veld is simpelweg onzichtbaar in de form view als de groepscheck faalt. Om te onderscheiden of een veld leeg of afgeschermd is: 1. Als admin lezen → geeft 0.0 (werkelijk leeg) of een waarde 2. Als technieker lezen → geeft False (afgeschermd) 3. In code: if record.maintenance_price is False → afgeschermd of geen waarde Veiligheidsregel: field-level groups is security (ORM-niveau), attrs-groups is alleen UI — vertrouw nooit alleen op attrs!
6.7 Audit exports — kritieke events exporteren

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']})
✏ Mini-oefening

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
Stap 1: Haal tracking values op voor 'stage_id' veld: tvs = search_read('mail.tracking.value', [['field','=','stage_id'], ['mail_message_id.model','=','fsm.order'], ['mail_message_id.date','>=', cutoff]], ['mail_message_id','old_value_char','new_value_char']) Stap 2: Join met mail.message voor datum + auteur: msg_ids = [t['mail_message_id'][0] for t in tvs] msgs = search_read('mail.message', [['id','in',msg_ids]], ['id','date','author_id','res_id']) msg_map = {m['id']: m for m in msgs} Stap 3: Schrijf CSV for tv in tvs: msg = msg_map[tv['mail_message_id'][0]] writer.writerow({'date': msg['date'], 'user': msg['author_id'][1], 'order_id': msg['res_id'], 'from': tv['old_value_char'], 'to': tv['new_value_char']})
6.8 Incidentprocedure — detectie, blokkering en forensiek

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']))
✏ Mini-oefening

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
Opmerking: als de record rules correct werken, kan Jonas de records van anderen NIET openen. De access-poging wordt gelogd in /var/log/odoo/odoo.log als een AccessError. Forensische reconstructie: 1. grep "AccessError\|access denied" /var/log/odoo/odoo.log | grep jonas 2. Via mail.message: Jonas kan geen berichten achterlaten op records die hij niet ziet 3. Via ir.logging (server errors): search_read('ir.logging', [['create_uid','=',jonas_uid],['level','=','ERROR']], ['name','message','create_date']) Blokkeren: execute_kw(db, admin_uid, admin_pw, 'res.users', 'write', [[jonas_uid], {'active': False}]) # Jonas kan niet meer inloggen maar historische data blijft intact
# 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)]
⚙ Deliverable Vak 3

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.

Vak 4 · Weken 19–24 · 48 contacturen

OCA &
Configuratie

Odoo Community Association modules installeren, begrijpen en configureren. Van fieldservice_stock tot fieldservice_agreement. De brug naar addon development.

● Vak 4 — OCA & Configuratie
Leerdoelstellingen
Na dit vak kan je:
OCA modules installeren en updaten
fsm.order datamodel volledig gebruiken
fieldservice_stock configureren voor onderdelen flow
Onderhoudscontracten aanmaken (fieldservice_agreement)
Custom velden toevoegen via Odoo interface
Automated actions en scheduled actions schrijven
De parts-flow koppelen aan DispatchIQ constraints
OCA broncode lezen en begrijpen
Cursusinhoud
Hoofdstukken
H1
OCA Ecosysteem & Installatie
// wat is OCA, hoe installeer je modules correct
Week 19 · 8u

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.

1.1 OCA organisatie — GitHub structuur en repositories

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
✏ Mini-oefening

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
1. github.com/OCA/field-service → map fieldservice_stock 2. Klik op de badge bovenaan de README voor installatiestatus (Production/Beta) 3. Branches: links bovenaan bij de bestandslijst → klik op branch dropdown → je ziet 14.0, 15.0, 16.0, 17.0 4. Issues: ga naar de repo root → Issues tab → filter op label "fieldservice_stock" 5. Noteer ook: welke andere modules vermeldt de README als dependency? (fieldservice en stock zijn verplicht, fieldservice_fleet is optioneel)
1.2 field-service repository — modules en dependency tree

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',
  ]
}
✏ Mini-oefening

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
fieldservice_agreement depends on: - fieldservice (OCA basis) - fieldservice_account (OCA facturatie) → depends on: fieldservice, account - sale_management (Odoo core) - account (Odoo core) Volledige tree voor DispatchIQ: fieldservice_agreement ├── fieldservice │ └── stock (core) ├── fieldservice_stock │ ├── fieldservice │ └── stock └── fieldservice_account ├── fieldservice └── account Je hebt dus MINIMAAL nodig in addons_path: - OCA/field-service/fieldservice - OCA/field-service/fieldservice_stock - OCA/field-service/fieldservice_account - OCA/field-service/fieldservice_agreement
1.3 Installatie via git — addons-path en Docker

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
✏ Mini-oefening

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
1. ssh naar je Hetzner server 2. cd /opt/odoo/extra-addons 3. git clone -b 17.0 --depth=1 https://github.com/OCA/field-service.git 4. Pas docker-compose.yml aan: volumes: - /opt/odoo/extra-addons/field-service:/mnt/extra-addons/field-service command: odoo --addons-path=...,/mnt/extra-addons/field-service 5. docker compose up -d 6. docker compose exec odoo odoo -d technicool -u base --stop-after-init 7. Odoo UI → Apps → verwijder filter "Apps" → zoek "fieldservice_stock" → Installeer Als de module niet verschijnt: controleer of addons_path correct is in odoo.conf en herstart
1.4 Module dependencies — __manifest__.py begrijpen

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,
}
✏ Mini-oefening

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
Verplichte velden: 'name' is de enige absolute vereiste. In de praktijk ook altijd: version, depends, data, installable. auto_install: True betekent dat Odoo de module automatisch installeert zodra ALLE modules in 'depends' geïnstalleerd zijn. Gebruik dit alleen voor "bridge" modules die twee andere modules combineren. Voorbeeld: fieldservice_account heeft auto_install: True — zodra zowel 'fieldservice' als 'account' geïnstalleerd zijn, wordt fieldservice_account automatisch actief. Voor dispatch_iq_horeca: auto_install: False — we willen expliciet control over wanneer de module actief wordt, niet automatisch bij elke fieldservice installatie.
1.5 OCA broncode lezen — mappenstructuur

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
✏ Mini-oefening

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
person_id in fsm_order.py: person_id = fields.Many2one('fsm.person', string='Assigned To', tracking=True, ...) → Ja, tracking=True — wijzigingen worden gelogd in chatter In views/fsm_order_views.xml: <field name="person_id" ...> → In sommige stages readonly via attrs="{'readonly': [('stage_id.is_closed','=',True)]}" → Afgesloten orders kunnen niet meer hertoegewezen worden Dit is nuttige info voor DispatchIQ: - We kunnen person_id niet wijzigen als de order al closed is - Onze Gantt drag moet dit controleren VOOR hij de API call doet - Foutmelding "Werkorder is afgesloten" moet proactief getoond worden
1.6 Versie compatibiliteit — branches per Odoo versie

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
✏ Mini-oefening

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
Op GitHub: klik de branch dropdown → vergelijk module-mappen tussen 16.0 en 17.0. Typisch zijn sommige modules in 17.0 nog "in progress" of missen ze. OCA Runbot: runbot.odoo-community.org → filter op 17.0 → zoek field-service → Je ziet per module een groen/rood/oranje status Als een module nog niet op 17.0 staat, heb je opties: 1. Port de module zelf (fork + aanpassen voor 17.0 API changes) 2. Gebruik een alternatieve OCA module 3. Wacht op de OCA port (check de Github issues voor "port to 17.0" labels) 4. Bouw de functionaliteit zelf in je dispatch_iq_horeca module
1.7 Pinning strategie — reproduceerbare deploys

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
✏ Mini-oefening

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
check_oca_versions.sh: #!/bin/bash LOCK_FILE="oca_versions.lock" FAIL=0 while IFS=': ' read -r repo commit; do CURRENT=$(git -C "extra-addons/$repo" rev-parse HEAD 2>/dev/null) if [ "$CURRENT" != "$commit" ]; then echo "DRIFT: $repo → verwacht $commit, huidig $CURRENT" FAIL=1 else echo "OK: $repo @ $commit" fi done < "$LOCK_FILE" exit $FAIL Uitvoeren voor deploy: chmod +x check_oca_versions.sh ./check_oca_versions.sh || exit 1 # stop deploy als versies afwijken
1.8 Upgrade rehearsal — updates testen op staging

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
✏ Mini-oefening

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
DispatchIQ post-upgrade checklist: □ 1. FSM order aanmaken met technieker en locatie → slaat correct op □ 2. Parts check endpoint: GET /api/constraints/parts?orderId=X&techId=Y → geeft correct JSON terug □ 3. Gantt drag-and-drop in de UI → herschrijft person_id en scheduled_date in Odoo □ 4. Werkbon PDF genereren → download zonder error, bevat correcte data □ 5. Stock picking bij orderafsluiting → voertuigstock wordt correct verminderd Extra checks bij stock-gerelateerde updates: □ 6. Min/max replenishment → interne transfer aangemaakt □ 7. Voertuiglocatie koppeling → fleet.vehicle.stock_location_id nog beschikbaar □ 8. Chatter tracking op fsm.order → status changes nog zichtbaar
# 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
⚙ Praktijkopdracht H1

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?

H2
fsm.order Diepgang
// alle kernvelden, stages en OCA uitbreidingen
Week 20 · 8u

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.

2.1 fsm.order velden — het complete model

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]
✏ Mini-oefening

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
Alle velden ophalen: fields_info = models.execute_kw(db, uid, pw, 'fsm.order', 'fields_get', [], {'attributes': ['string','type']}) print(len(fields_info)) # typisch 50-80 velden met OCA modules Meest relevante voor Gantt: 1. person_id → op welke rij in de Gantt (technieker) 2. scheduled_date_start → x-positie van de balk (begin) 3. scheduled_date_end → breedte van de balk (einde) 4. name → label van de balk 5. priority → kleur/prioriteit markering Bonus: stage_id.is_closed → afgesloten orders grijs tonen in Gantt
2.2 fsm.order stages — workflow configureren

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>
✏ Mini-oefening

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
1. Field Service → Configuratie → Stages → Nieuw 2. Maak alle stages aan met correcte sequence 3. Stel is_closed=True in op "Gefactureerd" (en evt. "Geannuleerd") 4. Zet een order op "Gefactureerd" Wat verandert: - De order wordt grijs in kanban view - Het veld person_id wordt readonly (kan technieker niet meer wijzigen) - In DispatchIQ: de Gantt-balk krijgt een andere stijl (grey out of doorstreept) - XML-RPC: stage_id.is_closed = True → constraint check voorkomt herplannen Via API: stage = search_read('fsm.stage', [['id','=', order['stage_id'][0]]], ['is_closed']) if stage[0]['is_closed']: raise Exception('Order is afgesloten, herplannen niet mogelijk')
2.3 fieldservice_stock integratie — product_ids en pickings

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']}
)
✏ Mini-oefening

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
1. FSM Order → tabblad "Onderdelen" → product_ids → voeg COND-R32 toe, qty=1 2. Zet order op stage "Afgerond" (is_closed=True) 3. Odoo trigger: _action_done() maakt automatisch een stock.picking aan 4. Voorraad → Operaties → Transfers → filter State=Done → Je ziet een picking: WH/Stock/Jonas → Partners/Klant (of Virtual/Production) → State = Done of Ready (afhankelijk of je manueel valideert) Als de picking NIET aangemaakt wordt: - Controleer of fieldservice_stock correct geïnstalleerd is - Controleer of vehicle stock_location_id gekoppeld is - Check Odoo log voor errors bij de stage-transitie
2.4 Parts flow volledig — vaststelling tot herstelorder

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>
✏ Mini-oefening

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
Sequentie: Technieker → sluit vaststelling af → fsm.order.stage = "Wacht op stock" Dispatcher → ziet order in "Wacht op stock" → maakt purchase.order aan Leverancier → levert → purchase.order ontvangen → stock.move.state = done Automated action → detecteert done move → zoekt fsm.orders "Wacht op stock" met dit product System → zet orders terug naar "Ingepland" Dispatcher → plant technieker opnieuw in Riskantste stap: de automated action in stap 4/5. Als die faalt → orders blijven hangen in "Wacht op stock" en worden nooit herpland. Fallback strategie: 1. Cron job die elke ochtend "Wacht op stock" orders controleert op stockbeschikbaarheid 2. Dashboard metric: "Orders in Wacht op stock ouder dan 5 dagen" → alert dispatcher 3. Handmatig: dispatcher kan order altijd manueel herplannen
2.5 fsm.template — checklists per apparaattype

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}]
)
✏ Mini-oefening

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
1. Field Service → Configuratie → Templates → Nieuw 2. Naam: "Koelinstallatie R32 jaarlijkse keuring" 3. Checkpunten toevoegen via tabblad Checklist: - Werkdruk meten (suction + discharge) - Koelmiddelniveau controleren - LEK-test met elektronische detector - Elektrische verbindingen nakijken (ampères meten) - Isolatie warmtewisselaar inspecteren - Condensor reinigen (stofvrij, geen begroeiing) 4. Sla op → kopieer ID 5. Maak order aan → veld Template_id = deze template 6. Log in als Jonas → open order → tabblad Checklist → vink items af één voor één 7. Observeer: kan je de order afsluiten als niet alle items afgevinkt zijn?
2.6 API calls voor parts check — stock.quant queries

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 };
}
✏ Mini-oefening

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
Test geval 1: Jonas heeft COND-R32 (qty=3) op zijn voertuig, order vraagt qty=1 → Response: {"ok": true, "missing": [], "available": [{"product":"COND-R32","qty_needed":1,"qty_available":3}]} Test geval 2: Jonas heeft COND-R32 (qty=0), order vraagt qty=1 → Response: {"ok": false, "missing": [{"product":"COND-R32","qty_needed":1,"qty_available":0}]} Edge cases om te testen: - Order heeft geen product_ids → {"ok": true, "missing": [], "available": []} - Technieker heeft geen voertuig gekoppeld → 404 of {"ok": false, "error": "Geen voertuig gevonden"} - Odoo is onbereikbaar → 503 service unavailable
2.7 Lifecycle regels — wanneer mag een order afgesloten worden

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.')
✏ Mini-oefening

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
Velden toevoegen aan het model: signature = fields.Binary('Handtekening') force_close = fields.Boolean('Forceer afsluiting', groups='group_dispatcher') Test: 1. Maak order met template (6 checkpunten) 2. Vink slechts 4 af 3. Probeer op "Afgerond" te zetten → ValidationError verschijnt Dispatcher test: 4. Vink force_close aan (enkel zichtbaar voor dispatchers) 5. Zet op "Afgerond" → werkt ondanks open items 6. In chatter: automatisch gelogd dat force_close gebruikt werd (tracking=True op het veld) Als technieker Jonas: force_close veld is onzichtbaar en kan niet worden aangepast
2.8 Datacontracten — API stabiliteit garanderen

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]),
  };
}
✏ Mini-oefening

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
// tests/mapOrder.test.js (Jest) import { mapOrder } from '../src/mappers/order.js' test('mapOrder handles empty Odoo record', () => { const result = mapOrder({ id: 1 }) expect(result.reference).toBe('') expect(result.client).toBeNull() expect(result.technicianId).toBeNull() expect(result.products).toEqual([]) expect(result.isClosed).toBe(false) // Geen undefined waarden in de output Object.values(result).forEach(v => expect(v).not.toBeUndefined() ) }) test('mapOrder maps full Odoo record correctly', () => { const raw = { id: 42, name: 'FSM/2026/0042', partner_id: [12,'Klant'], ... } const result = mapOrder(raw) expect(result.id).toBe(42) expect(result.reference).toBe('FSM/2026/0042') })
# 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
⚙ Praktijkopdracht H2

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.

H3
fieldservice_stock & parts flow
// stock.picking vanuit order en onderdelenflow
Week 21 · 8u

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.

3.1 Order-product koppeling — hoeveelheden per interventie

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}, ...]
✏ Mini-oefening

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
qty_done is initieel 0.0. Het wordt ingevuld wanneer: 1. De technieker de order afsluit (stage → is_closed=True) 2. Odoo maakt dan een stock.picking aan met de gevraagde hoeveelheden 3. Bij validatie van de picking wordt qty_done bijgewerkt Als de technieker minder verbruikte dan gevraagd: - product_uom_qty = 1.0, qty_done = 0.5 → retour voor de rest - Dit is de basis voor de retourflow in sub-les 3.8 Via API voor DispatchIQ dashboard: lines = search_read('fsm.order.product', [['order_id','=',id]], ['product_id','product_uom_qty','qty_done']) efficiency = sum(l['qty_done'] for l in lines) / sum(l['product_uom_qty'] for l in lines)
3.2 Vehicle stock filter — quant queries per voertuig

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
✏ Mini-oefening

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
quantity=5, reserved=0 → effectief beschikbaar = 5 quantity=5, reserved=3 → effectief beschikbaar = 2 Reservatie treedt op wanneer: - Een stock.picking in state 'confirmed' of 'assigned' een reservation heeft op dat product - Bijv: Jonas heeft morgen nog een order met COND-R32 → die qty is al gereserveerd Voor DispatchIQ is dit essentieel: als Jonas COND-R32 qty=5 heeft maar morgen al 3 gereserveerd zijn, kan hij vandaag maar 2 extra orders aannemen die COND-R32 nodig hebben. Implementatie check: available = quant['quantity'] - quant['reserved_quantity'] # Gebruik NOOIT enkel quant['quantity'] zonder reservations te aftrekken!
3.3 Parts tekort signaleren — constraint dialoog in de Gantt

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);
}
✏ Mini-oefening

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
function showConstraintDialog({ title, missing, message }) { return new Promise(resolve => { const modal = document.createElement('div') modal.className = 'constraint-modal' modal.innerHTML = ` <div class="modal-overlay"> <div class="modal-box"> <h3>⚠ ${title}</h3> <table> ${missing.map(m => ` <tr> <td>${m.product}</td> <td style="color:red">Nodig: ${m.needed} / Beschikbaar: ${m.available}</td> </tr>`).join('')} </table> <p>${message}</p> <button id="confirm">Toch inplannen (met risico)</button> <button id="cancel">Annuleren</button> </div> </div>` document.body.appendChild(modal) modal.querySelector('#confirm').onclick = () => { modal.remove(); resolve(true) } modal.querySelector('#cancel').onclick = () => { modal.remove(); resolve(false) } }) }
3.4 Van vaststelling naar herstel — volledige flow

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;
}
✏ Mini-oefening

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
// Express endpoint app.post('/api/orders/:id/create-repair', async (req, res) => { const { missingParts } = req.body if (!missingParts?.length) return res.status(400).json({error:'missingParts required'}) try { const repairId = await createRepairOrder(parseInt(req.params.id), missingParts) res.json({ repairId }) } catch (err) { res.status(500).json({ error: err.message }) } }) Verificatie via XML-RPC na de call: repair = search_read('fsm.order', [['id','=',repairId]], ['name','stage_id','product_ids']) assert repair[0]['name'].endswith('/HERSTEL') assert repair[0]['stage_id'][1] == 'Wacht op stock' lines = search_read('fsm.order.product', [['order_id','=',repairId]], ['product_id']) assert len(lines) == len(missingParts)
3.5 Stock picking synchronisatie — reservatie en verbruik

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']
)
✏ Mini-oefening

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
1. Order aanmaken + bevestigen (stage → confirmed) 2. Check: search_read('stock.quant', [['location_id','=',vehicle_loc_id]], ['product_id','quantity','reserved_quantity']) → reserved_quantity = 1 voor elk product 3. Order annuleren: execute_kw(..., 'fsm.order', 'action_cancel', [[order_id]]) 4. Check stock.picking: search_read('stock.picking', [['fsm_order_id','=',order_id]], ['state']) → state = 'cancel' 5. Check stock.quant opnieuw: → reserved_quantity = 0 (vrijgegeven) Als reserved_quantity NIET daalt na annulatie: → action_cancel() roept pickings.action_cancel() niet aan → Dit is een bug: andere orders worden onterecht geblokkeerd op de parts-check
3.6 API contract — consistent JSON schema

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() })),
});
✏ Mini-oefening

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
// test/parts-check-schema.test.js import { PartsCheckSchema } from '../src/schemas/parts.js' test('parts check response matches schema', async () => { const res = await fetch('/api/constraints/parts?orderId=1&techId=3') const data = await res.json() const result = PartsCheckSchema.safeParse(data) expect(result.success).toBe(true) if (!result.success) console.error(result.error) }) Als je een veld vergeet: - Zod gooit een ZodError met exact welk veld ontbreekt en op welk pad - Bijv: "missing" → array moet items hebben maar got undefined - Dit vang je vroeg in development op, vóór de frontend crasht op runtime
3.7 Partial availability — deels beschikbare onderdelen

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
  };
}
✏ Mini-oefening

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
Verwachte response: { ok: false, partial: true, score: 0.333, // 1 van 3 volledig beschikbaar available: [{ product: 'A', needed: 1, available: 3 }], missing: [ { product: 'B', needed: 2, available: 1 }, // deels { product: 'C', needed: 1, available: 0 }, // volledig weg ] } Gantt visueel: - ok: true → groene rand (alles ok) - ok: false, partial: false → rode rand (niets beschikbaar) - ok: false, partial: true → oranje rand (deels beschikbaar, mogelijk planbaar) Score gebruiken voor prioriteit: score=0.9 → snel aanvulbaar, score=0.0 → blokkeer inplanning
3.8 Retourflow — ongebruikte onderdelen terugboeken

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]
  })
✏ Mini-oefening

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
1. Maak order aan met 3x COND-R32 → sluit af (picking verbruikt 3 stuks) 2. Check stock.quant → vehicle_loc: COND-R32 qty is gedaald met 3 3. Maak retour picking: create_return_picking(order, [{'product_id': cond_r32_id, 'qty_returned': 2}]) 4. Valideer picking: picking = search_read('stock.picking', [['origin','like',order_name+'/RETOUR']], ['id']) execute_kw(db, uid, pw, 'stock.picking', 'action_done', [[picking[0]['id']]]) 5. Check stock.quant: → COND-R32 qty op vehicle_loc stijgt met 2 (retour geboekt) Totaalbalans: verbruikt 3, terug 2 → netto verbruik = 1 Dit is de correcte boekhouding voor je onderdelen rapport.
// 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 }
  ]
}
⚙ Praktijkopdracht H3

Bouw het endpoint /api/constraints/parts dat per order en technieker een lijst ontbrekende onderdelen teruggeeft op basis van voertuigvoorraad.

H4
fieldservice_agreement
// onderhoudscontracten en recurring orders
Week 22 · 8u

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.

4.1 Contract templates — onderhoudspatronen definiëren

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.',
  }]
)
✏ Mini-oefening

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
1. Field Service → Agreements → Nieuw 2. Partner: Brasserie De Zwaan, Location: hun keuken, Template: Combi-steamer jaarlijks 3. recurrence_type: yearly, next_date: volgende maand datum 4. Sla op → Klik knop "Genereer orders" (action_generate_orders) 5. Resultaat: 1 fsm.order aangemaakt met: - partner_id = Brasserie De Zwaan - template_id = Combi-steamer jaarlijks checklist - scheduled_date_start = next_date - stage = Nieuw (of default stage) - origin = agreement naam Via XML-RPC verificatie: search_read('fsm.order', [['origin','=', agreement_name]], ['name','scheduled_date_start'])
4.2 Recurrence regels — automatische ordergeneratie

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`);
  }
});
✏ Mini-oefening

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
1. Update agreement next_date naar gisteren: execute_kw(db, uid, pw, 'fieldservice.agreement', 'write', [[agreement_id], {'next_date': '2026-03-12'}]) 2. Trigger generatie manueel (via Odoo scheduler of direct call): execute_kw(db, uid, pw, 'fieldservice.agreement', 'action_generate_orders', [[agreement_id]]) 3. Check de order: orders = search_read('fsm.order', [['origin','=', ag_name]], ['name','scheduled_date_start']) → 1 nieuwe order, scheduled_date_start = 2026-03-12 4. Check next_date op agreement: ag = read('fieldservice.agreement', [agreement_id], ['next_date']) → next_date = '2027-03-12' (1 jaar later voor yearly) Als next_date niet schuift: actie_generate_orders is niet correct gebonden aan recurrence update
4.3 SLA velden — responstijd en prioriteit

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
✏ Mini-oefening

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
1. Voeg de inheritance class toe aan je dispatch_iq_horeca module 2. Update module: odoo -u dispatch_iq_horeca 3. Field Service → Agreements → kies agreement → sla_priority = 'Urgent' 4. Genereer order → check order.priority = '1' Gantt visueel: - priority '0' → normale witte/grijze balk - priority '1' → oranje balk of ★ symbool - priority '2' → rode balk + badge "24/7" Implementatie in React Gantt: const barClass = { '0': 'bar-normal', '1': 'bar-urgent', '2': 'bar-critical', }[order.priority] || 'bar-normal'
4.4 Bundeling per site — meerdere toestellen combineren

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])],
  }]
)
✏ Mini-oefening

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
1. Maak 3 equipment records aan met location_id = dezelfde fsm.location 2. Haal ze op: equip_ids = search('horeca.equipment', [['location_id','=',loc_id]]) 3. Maak order aan met equipment_ids = [(6,0,equip_ids)] Werkbon aanpak: - QWeb template loopt over o.equipment_ids met t-foreach - Per toestel: naam, merk, serienummer, koelmiddel als apart blok - Checklist per toestel: t-foreach="o.template_id.checklist_ids" Vraag: welke checklist bij gebundelde order? Optie 1: één generieke site-checklist op de order template Optie 2: per equipment_id een aparte template via Many2many → Meest elegant: equipment.category_id heeft een default_template_id → De werkbon toont dan per toestel zijn eigen checklist
4.5 Klantcommunicatie — bevestigingen en reminders

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>
✏ Mini-oefening

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
1. Technisch → E-mail → Templates → Nieuw 2. Model: fsm.order, vul subject en body_html in met ${object.name} variabelen 3. Instellingen → Technisch → Automatische Acties → Nieuw - Trigger: Stage verandering (on_write) - Filter: stage_id.name = 'Ingepland' - Actie: Stuur e-mail → kies je template 4. Testmail sturen: open een order → More → Stuur e-mail → kies template → Verstuur Problemen checken: - ${object.person_id.name} geeft "False" → person_id niet ingevuld op de order - E-mail komt niet aan → check spam of smtp configuratie - Template laadt niet → check of model_id correct is in het template record
4.6 Contractrapportage — lopende en verlopen agreements

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 };
}
✏ Mini-oefening

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
// Express endpoint app.get('/api/agreements/report', async (req, res) => { const report = await agreementsReport() res.json(report) }) Test agreements aanmaken: 1. Actief: start=2025-01-01, end=2027-12-31 → verschijnt in active[] 2. Verlopen binnenkort: start=2025-01-01, end=2026-04-10 → in expiringSoon[] (< 60 dagen) 3. Verlopen: start=2024-01-01, end=2025-12-31 → in expired count Verwacht rapport: { active: 1, expiringSoon: [{name:'...', end_date:'2026-04-10'}], expired: 1 } Dashboard toepassing: - Toon badge "1 contract verloopt binnenkort" in DispatchIQ header - Link naar de lijst van expiringSoon agreements voor dispatcher
4.7 Seizoenslogica — frequentie aanpassen per periode

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
✏ Mini-oefening

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
Override in model: def action_generate_orders(self): result = super().action_generate_orders() for agreement in self: if agreement.category == 'kitchen': adjusted = adjust_for_season(agreement.next_date, 'kitchen') if adjusted != agreement.next_date: agreement.write({'next_date': adjusted}) agreement.message_post(body=f'Next date aangepast van {agreement.next_date} naar {adjusted} (seizoensaanpassing)') return result Test: 1. agreement.next_date = 2026-07-15 2. action_generate_orders() aanroepen 3. Verwacht: agreement.next_date = 2026-09-01 4. In chatter: logbericht over de aanpassing Check ook: - next_date = 2026-06-30 → geen aanpassing (juni is geen zomerperiode) - next_date = 2026-08-31 → aangepast naar 2026-09-01
4.8 Capaciteitsguardrails — generatie begrenzen op beschikbaarheid

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
✏ Mini-oefening

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
Test setup: tomorrow = date.today() + timedelta(days=1) for i in range(8): # maak 8 orders aan voor morgen create_fsm_order(scheduled_date_start=tomorrow) Aanroep: next_available = get_first_available_date(env, tomorrow) # → geeft overmorgen (dag+2), want morgen heeft al 8 orders Als overmorgen ook vol is: → De loop gaat naar dag+3, dag+4... tot max 30 dagen → Als geen dag gevonden binnen 30 dagen → retourneert None Foutafhandeling: if next_available is None: raise UserError('Geen beschikbare dag gevonden binnen 30 dagen. Vergroot het team of verhoog max_per_day.') # Stuur ook een alert naar de dispatcher
# 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`);
  }
}
⚙ Praktijkopdracht H4

Maak een jaarlijks onderhoudscontract voor een horeca-site en laat Odoo automatisch de volgende 2 interventies genereren.

H5
Automated actions & crons
// server actions, scheduled jobs, workflow automation
Week 23 · 8u

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.

5.1 Server actions — Python snippets op events

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>
✏ Mini-oefening

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
Server action code: from datetime import datetime, timedelta cutoff = datetime.now() - timedelta(days=5) for order in records: if (order.stage_id.name == 'Wacht op stock' and order.create_date and order.create_date < cutoff): order.message_post( body=f'⏰ Order {order.name} staat al meer dan 5 dagen op "Wacht op stock". Actie vereist.', message_type='comment', subtype_xmlid='mail.mt_note' ) Testen: 1. Technisch → Server Acties → installeer de actie 2. Field Service → Orders → selecteer 3+ orders 3. Actie knop → kies "Escaleer wachtende orders" 4. Check chatter van geselecteerde orders
5.2 Automated actions — triggers op veldwijzigingen

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>
✏ Mini-oefening

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
base.automation config: - trigger: on_write - filter_pre_domain: [('stage_id.name','!=','Ingepland')] - filter_domain: [('stage_id.name','=','Ingepland')] - action code: for order in records: tech = order.person_id.name if order.person_id else 'onbekend' slot = order.scheduled_date_start or 'nog niet ingesteld' order.message_post( body=f'✅ Ingepland: {tech} op {slot}', subtype_xmlid='mail.mt_note' ) Test: 1. Maak order aan in stage "Nieuw" 2. Wijs technieker + tijdslot toe 3. Zet stage op "Ingepland" 4. Controleer chatter: notitie verschijnt met techniekernaam en tijdslot
5.3 Cron jobs — dagelijkse health checks

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>
✏ Mini-oefening

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
Cron code: from datetime import date, timedelta tomorrow = date.today() + timedelta(days=1) count = env['fsm.order'].search_count([ ('scheduled_date_start','>=', str(tomorrow) + ' 00:00:00'), ('scheduled_date_start','<', str(tomorrow) + ' 23:59:59'), ('stage_id.is_closed','=', False) ]) env['ir.logging'].sudo().create({'name':'DispatchIQ', 'type':'server', 'message': f'Morgen gepland: {count} orders', 'path':'cron_daily_summary','func':'run','line':1,'level':'info'}) Manueel triggeren: 1. Instellingen → Technisch → Geplande acties → kies jouw cron 2. Klik knop "Voer handmatig uit" bovenaan 3. Odoo voert de code onmiddellijk uit 4. Check resultaat: Instellingen → Technisch → Logging → filter op 'DispatchIQ'
5.4 Notificatieflows — mail templates per statuswijziging

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>
✏ Mini-oefening

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
1. Maak mail template "Reminder: interventie morgen" aan 2. Maak automated action aan: - trigger: on_time - trg_date_id: scheduled_date_start - trg_date_range: -24 (24u vóór) - trg_date_range_type: hours - filter: stage_id.is_closed = False - action: template.send_mail(object.id, force_send=True) 3. Test: - Stel scheduled_date_start in op nu + 23u - Ga naar Instellingen → Geplande acties → "Basis automatiseringsregel polling" - Voer handmatig uit → de on_time triggers worden geëvalueerd - Check of de mail verzonden werd (Technisch → Emails → Sent) Tip: gebruik een testmail (eigen mailbox) als partner email voor snelle verificatie
5.5 Retry patterns — robuuste herpoging bij fouten

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', ...));
✏ Mini-oefening

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
Fout simuleren: const odoo = new OdooClient({ url: 'http://wrong-host:8069' }) Bij aanroep withRetry(() => odoo.searchRead(...)): Console output: "Poging 1 mislukt, retry na 2000ms: ECONNREFUSED" (2 seconden wachten) "Poging 2 mislukt, retry na 4000ms: ECONNREFUSED" (4 seconden wachten) "Poging 3 mislukt, retry na 8000ms: ECONNREFUSED" Throw: Error: ECONNREFUSED Totale wachttijd: 2 + 4 = 6 seconden vóór de finale throw. Verbeter: voeg jitter toe om thundering herd te vermijden: const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500; Circuit breaker: na X mislukte pogingen alle calls blokkeren tot service hersteld is
5.6 Monitoring — logging en alerts voor mislukte jobs

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 };
}
✏ Mini-oefening

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
Test cron code: raise Exception('DispatchIQ: Test error voor monitoring verificatie') Na uitvoering: errors = search_read('ir.logging', [['level','=','ERROR'],['message','ilike','Test error']], ['name','message','create_date']) → 1 record met je error message Express health endpoint: app.get('/health/crons', async (req, res) => { const result = await checkCronHealth() res.status(result.healthy ? 200 : 503).json(result) }) Docker health check toevoegen in docker-compose.yml: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health/crons"] interval: 5m timeout: 10s retries: 3
5.7 Idempotency keys — dubbele acties vermijden

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;
}
✏ Mini-oefening

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
Test: const r1 = await idempotentCreate(42, 'create-repair', () => createRepairOrder(42, parts)) const r2 = await idempotentCreate(42, 'create-repair', () => createRepairOrder(42, parts)) r1 → { repairId: 123 } // nieuwe order aangemaakt r2 → { skipped: true, key: 'abc...' } // geskipped Verificatie in Odoo: orders = search_read('fsm.order', [['name','ilike','FSM/2026/0042/HERSTEL']], ['name']) → slechts 1 order (niet 2) Dag-boundary edge case: Als de dag verandert (key is andere dag), wordt een nieuwe actie WEL uitgevoerd. Als je dit niet wilt: gebruik een stabiele datum uit de order (scheduled_date_start) in de key, niet de current date.
5.8 Dead-letter aanpak — mislukte records opvangen

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 herprobe​ren 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,
      })
✏ Mini-oefening

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
Model definitie (models/failed_action.py): class DispatchFailedAction(models.Model): _name = 'dispatch.failed.action' _description = 'Mislukte automatische actie' record_model = fields.Char('Model', required=True) record_id = fields.Integer('Record ID', required=True) action = fields.Char('Actie', required=True) last_error = fields.Text('Laatste fout') retry_count = fields.Integer('Pogingen', default=0) resolved = fields.Boolean('Opgelost') Test: def failing_action(record): raise Exception('Testfout') # Eerste aanroep: safe_execute_action(env, order, failing_action) # → dispatch.failed.action aangemaakt, retry_count=1 # Tweede aanroep: safe_execute_action(env, order, failing_action) # → bestaand record geüpdated, retry_count=2
# 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>
XML vs UI: Automated actions die je via de interface aanmaakt, worden bij een module-update overschreven. Definieer ze altijd in data/automated_actions.xml zodat ze versiegecontroleerd zijn en bij -u module correct herladen worden.
⚙ Praktijkopdracht H5

Automatiseer een flow die herstelorders aanmaakt wanneer onderdelen geleverd zijn en de oorspronkelijke vaststelling op "Wacht op stock" staat.

H6
Custom fields & IDispatchAdapter
// constraints koppelen aan Odoo data
Week 24 · 8u

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.

6.1 Custom velden — refrigerant_type, LEZ, site-toegang

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')
✏ Mini-oefening

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
1. Voeg FsmLocationExtension class toe aan models/ 2. Update __init__.py zodat het bestand geladen wordt 3. Installeer: odoo -u dispatch_iq_horeca 4. UI: Field Service → Locaties → stel lez_zone in 5. XML-RPC query: lez_locations = search_read('fsm.location', [['lez_zone','=','antwerp']], ['name','partner_id','lez_zone']) print(f'{len(lez_locations)} locaties in LEZ Antwerpen') Als het veld niet verschijnt na update: - Controleer of het model in __init__.py geïmporteerd is - Controleer of models/__init__.py alle bestanden importeert - Herstart Odoo na update
6.2 View uitbreiding — velden zichtbaar in fsm.order

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>
✏ Mini-oefening

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
1. Maak views/fsm_order_horeca_views.xml aan met de xpath uitbreiding 2. Voeg het bestand toe aan manifest 'data' lijst (NÁ ir.model.access.csv) 3. Update module: odoo -u dispatch_iq_horeca 4. Open een fsm.order → je ziet tabblad "Horeca details" 5. Stel refrigerant_type = R32 in → opslaan Verificatie: order = read('fsm.order', [order_id], ['refrigerant_type']) assert order[0]['refrigerant_type'] == 'R32' Als tabblad niet verschijnt: - Check of inherit_id correct is (ref moet bestaan) - Controleer xpath expr → //notebook moet bestaan in de target view - Try xpath expr="//page[@name='other_info']" als alternatief ankerpunt
6.3 Adapter pattern — IDispatchAdapter interface

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 };
  }
}
✏ Mini-oefening

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
class MockAdapter extends IDispatchAdapter { async checkParts() { return { ok: true, missing: [], available: [] } } async checkSkills() { return { ok: false, missing: ['R32-certificaat'] } } async checkAccess() { return { ok: true } } async checkZone() { return { ok: true } } } const adapter = new MockAdapter() const result = await adapter.checkAll(42, 3, 7) result.ok = false (want skills.ok = false → AND faalt) result.skills.missing = ['R32-certificaat'] In de Gantt: checkAll is de enige call die de UI doet bij drag-and-drop. Ze hoeft niet 4 aparte calls te doen — één checkAll geeft het volledige beeld. Latency: alle 4 checks draaien in parallel via Promise.all → max latency = langzaamste check.
6.4 Constraints API — alle checks als losse methods

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 };
}
✏ Mini-oefening

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
async checkZone(orderId, vehicleId) { const [order, vehicle] = await Promise.all([ odoo.searchRead('fsm.order', [['id','=',orderId]], ['location_id']), odoo.searchRead('fleet.vehicle', [['id','=',vehicleId]], ['tag_ids']), ]) const location = await odoo.searchRead('fsm.location', [['id','=',order[0].location_id[0]]], ['lez_zone']) const lez = location[0].lez_zone // 'antwerp', 'ghent', etc. if (lez === 'none') return { ok: true } const tagNames = await odoo.searchRead('fleet.vehicle.tag', [['id','in', vehicle[0].tag_ids]], ['name']) const hasTag = tagNames.some(t => t.name.toLowerCase().includes(lez)) return { ok: hasTag, reason: hasTag ? null : `Voertuig mag niet in LEZ ${lez} rijden` } } Test: order op locatie lez_zone='antwerp', voertuig zonder 'LEZ-Antwerpen' tag → { ok: false, reason: 'Voertuig mag niet in LEZ antwerp rijden' }
6.5 Consistente response — uniforme foutcodes

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, {});
}
✏ Mini-oefening

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
Severity definitie: - checkZone: severity = 'block' (wet, niet negeerbaar) - checkSkills: severity = 'block' voor veiligheidscertificaten (R32, F-gas) severity = 'warn' voor andere skills - checkParts: severity = 'warn' (dispatcher kan bewust beslissen) - checkAccess: severity = 'warn' (sleutelcode kan nog geregeld worden) Gantt UI gedrag: if (result.severity === 'block') { // Drag terugtrekken, rode toast: "Toewijzing geblokkeerd: [reden]" revertDrag() showToast('error', result.details.reason) } else if (result.severity === 'warn') { // Modal met keuze const confirmed = await showConstraintDialog(result) if (confirmed) proceedWithAssignment() else revertDrag() }
6.6 Demo validatie — constraints tonen in de planner

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)}`);
}
✏ Mini-oefening

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
Stap 1: Technieker 1 instellen - Skills: R32-certificaat, algemene monteur - Voertuig: tag LEZ-Antwerpen, tag Diesel-Euro6 → Kan Order 1 (normaal) en Order 2 (parts tekort maar doorgezet) uitvoeren Stap 2: Technieker 3 instellen - Skills: GEEN R32-certificaat - Order 3 vereist R32 → BLOCK_SKILLS Stap 3: Technieker 1 voertuig voor Order 4 - Locatie Order 4: LEZ Antwerpen MAAR voertuig heeft geen LEZ-Antwerpen tag → BLOCK_ZONE Als een scenario niet klopt: 1. Log de raw Odoo data: adapter.getRawData(orderId, techId) 2. Vergelijk skill_ids van order vs technieker 3. Vergelijk lez_zone van locatie vs tag_ids van voertuig
6.7 API versionering — backward compatible updates

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);
✏ Mini-oefening

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
Curl tests: curl "/api/v1/constraints/check?orderId=1&techId=3" → {"ok":false,"parts":{"ok":true},"skills":{"ok":false,"missing":["R32-certificaat"]}} # access en zone ontbreken → v1 schema curl "/api/v2/constraints/check?orderId=1&techId=3&vehicleId=7" → {"ok":false,"parts":{"ok":true},"skills":{"ok":false},"access":{"ok":true},"zone":{"ok":true}} # alle 4 aanwezig → v2 schema Jest test: test('v1 heeft enkel parts en skills', async () => { const data = await fetch('/api/v1/constraints/check?orderId=1&techId=3').then(r=>r.json()) expect(data).toHaveProperty('parts') expect(data).toHaveProperty('skills') expect(data).not.toHaveProperty('access') expect(data).not.toHaveProperty('zone') })
6.8 Observability hooks — trace-id en latency metrics

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;
    }
  };
}
✏ Mini-oefening

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
Wrapping: this.checkParts = withObservability(this.checkParts.bind(this), 'parts') this.checkSkills = withObservability(this.checkSkills.bind(this), 'skills') this.checkAccess = withObservability(this.checkAccess.bind(this), 'access') this.checkZone = withObservability(this.checkZone.bind(this), 'zone') Typische latencies (afhankelijk van netwerk naar Odoo): - checkParts: 45-80ms (2 queries: order lines + stock.quant) - checkSkills: 30-50ms (2 queries: order skills + person skills) - checkAccess: 20-30ms (1 query: location fields) - checkZone: 35-60ms (2 queries: location lez + vehicle tags) Traagste: doorgaans checkParts want stock.quant heeft veel records en geen index op location_id+product_id combo. Optimalisatie: voeg een database index toe op stock.quant(location_id, product_id) of gebruik een cache.
// 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>;
}
⚙ Deliverable Vak 4

Alle DispatchIQ constraints werken via echte Odoo data en worden in de planner als duidelijke waarschuwingen getoond.

Vak 5 · Weken 25–30 · 48 contacturen

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.

● Vak 5 — Odoo Addon Development
Leerdoelstellingen
Na dit vak kan je:
Een Odoo module aanmaken van nul
Models definiëren met Python (ORM)
Form, list, kanban views schrijven in XML
Computed fields en constraints implementeren
Bestaande Odoo models extenden (inheritance)
Een PDF werkbon genereren met QWeb
Wizards bouwen voor multi-step acties
Security rules schrijven (record rules, access)
Unit tests schrijven voor je module
Cursusinhoud
Hoofdstukken
H1
Module Structuur & Python ORM
// je eerste werkende module
Week 25 · 8u

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.

1.1 Module mapstructuur — de basismap van elke Odoo module

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
✏ Mini-oefening

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
Bash script om de structuur aan te maken: mkdir -p dispatch_iq_horeca/{models,views,security,data,report,tests} touch dispatch_iq_horeca/__init__.py touch dispatch_iq_horeca/models/__init__.py touch dispatch_iq_horeca/tests/__init__.py echo "from . import models" > dispatch_iq_horeca/__init__.py Minimale __manifest__.py: {'name':'DispatchIQ Horeca','version':'17.0.1.0.0', 'depends':['fieldservice','mail'],'installable':True,'data':[]} Verificatie: 1. Module in addons_path: docker compose restart odoo 2. Odoo UI → Apps → verwijder "Apps" filter → zoek "dispatch_iq_horeca" 3. Als hij verschijnt: structuur OK 4. Als hij niet verschijnt: check addons_path en herstart Odoo logs voor errors
1.2 __manifest__.py — het paspoort van je module

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,
}
✏ Mini-oefening

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
Fout bij verkeerde volgorde: "External ID not found in the system: fieldservice.model_fsm_order" of "You are trying to modify res.groups records without full administrator privileges" De reden: - automated_actions.xml verwijst naar model external IDs die pas bestaan na models-load - ir.model.access.csv verwijst naar groups die pas bestaan na security/groups.xml - Als groups.xml niet geladen is, kan access.csv de group reference niet vinden Vaste laadvolgorde: 1. groups.xml (groepen aanmaken) 2. access.csv (rights op groepen) 3. views/ (forms die fields verwachten die al geladen zijn) 4. report/ (QWeb templates) 5. data/ (records die models en views nodig hebben) 6. automated_actions (verwijst naar models en server actions in data/)
1.3 models/horeca_equipment.py — een model definiëren

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)
✏ Mini-oefening

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
models/__init__.py: from . import horeca_equipment from . import fsm_order_ext Na installatie in Odoo: Technisch → Database structuur → Modellen → horeca.equipment → Je ziet: id, name, serial_no, brand, location_id (FK), state, create_date, write_date, create_uid, write_uid → Plus mail.thread velden: message_follower_ids, message_ids, activity_ids In de echte PostgreSQL database: \d horeca_equipment → kolommen: id serial, name varchar, serial_no varchar, brand varchar, location_id integer (FK), state varchar Als model niet verschijnt: - Herstart Odoo (modules worden gecached) - Check __init__.py importeert het bestand - Check odoo.log voor ImportError of SyntaxError
1.4 Field types — alle Odoo veldtypes en wanneer te gebruiken

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)
✏ Mini-oefening

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
Via XML-RPC: equip = read('horeca.equipment', [equip_id], ['photo']) # photo = b'iVBORw0KGgo...' → base64-gecodeerde bytes # of als attachment=True: een URL string naar de attachment In QWeb template: <img t-att-src="'data:image/png;base64,' + o.photo.decode('utf-8')" style="max-width:200px"/> Als attachment=True (aanbevolen voor grote bestanden): <img t-att-src="'/web/image/horeca.equipment/%d/photo' % o.id" style="max-width:200px"/> Verschil: - attachment=False: opgeslagen in de database zelf (groot, traag voor grote bestanden) - attachment=True: opgeslagen in ir.attachment, database bevat enkel een referentie (efficiënter)
1.5 Computed fields — @api.depends en store=True

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
✏ Mini-oefening

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
Verificatie: equip = read('horeca.equipment', [equip_id], ['service_count']) # → service_count = 3 Store=True vs Store=False in PostgreSQL: \d horeca_equipment - store=True: kolom "service_count" bestaat in de tabel → waarde staat in DB - store=False: kolom bestaat NIET in de tabel → berekend bij elke ORM read Praktisch verschil: - store=True: je kan filteren/zoeken op dit veld: search([['service_count','>',5]]) - store=False: je KAN NIET filteren → alleen lezen - store=True is sneller bij veel records maar neemt DB-ruimte in - store=False is altijd actueel maar trager bij bulk reads Wanneer store=True kiezen: → Als je op het veld wil filteren of groeperen in lijstviews of rapporten
1.6 Python constraints — @api.constrains en ValidationError

@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.')
✏ Mini-oefening

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
Test 1: serial_no='AB' (te kort) → Python @api.constrains gooit: 'Serienummer "AB" is te kort. Minimum 5 tekens vereist.' → Odoo toont dit als een dialoogvenster met je eigen foutboodschap ✅ Test 2: duplicate serienummer → SQL constraint gooit: 'Serienummer moet uniek zijn binnen het bedrijf.' → Odoo toont ook een dialoogvenster ✅ Vergelijking: - Python constraint: flexibel, kan complexe logica bevatten, geeft Nederlandse foutboodschap - SQL constraint: sneller (DB-level), gaat ook af bij directe SQL inserts, maar message is minder flexibel Beste praktijk: gebruik BEIDE - SQL constraint voor uniekheid (database garantie) - Python constraint voor business logica en gebruiksvriendelijke messages
1.7 CRUD methoden overriden — create, write, unlink

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()
✏ Mini-oefening

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
1. Maak HorecaEquipment aan: "Combi-steamer ABC-12345" 2. Maak fsm.order aan met equipment_id = dit apparaat, stage = open 3. Probeer equipment te verwijderen → UserError: "Kan Combi-steamer ABC-12345 niet verwijderen: heeft nog open orders." 4. Sluit de order: zet stage op is_closed=True 5. Probeer equipment opnieuw te verwijderen → succesvol Let op: unlink() in de ORM werkt op een recordset (self kan meerdere records zijn). Controleer ELKE record in de loop, niet enkel self[0]. Extra check toevoegen: def unlink(self): for rec in self: open_count = len(rec.service_ids.filtered(lambda o: not o.stage_id.is_closed)) if open_count: raise UserError(f'Kan {rec.name} niet verwijderen: {open_count} open order(s).') return super().unlink()
1.8 Logging standaard — modulegerichte logger

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
        )
✏ Mini-oefening

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
Logger pad (te zien in odoo.log): 2026-03-15 10:23:41,123 INFO odoo.addons.dispatch_iq_horeca.models.horeca_equipment: HorecaEquipment aangemaakt: Combi-steamer XYZ (EQUIP/2026/0001) Loglevel instellen voor jouw module: Instellingen → Technisch → Logging → Nieuw Logger name: odoo.addons.dispatch_iq_horeca Level: DEBUG (voor development) Productie: gebruik enkel INFO en hoger Development: gebruik DEBUG tijdelijk om veldwaarden te tracken Tip: gebruik NOOIT print() in Odoo code. Gebruik altijd _logger. print() statements komen terecht in docker logs maar zijn niet filterbaar en geven geen tijdstempel of logger-context.
1.9 Migratievoorbereiding — versies en data scripts

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')
✏ Mini-oefening

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
1. __manifest__.py: 'version': '17.0.1.1.0' 2. mkdir -p dispatch_iq_horeca/migrations/17.0.1.1.0 3. touch dispatch_iq_horeca/migrations/17.0.1.1.0/__init__.py (lege file) 4. Maak post-migrate.py aan met het script 5. Update uitvoeren: docker compose exec odoo odoo -d technicool -u dispatch_iq_horeca --stop-after-init 6. Logs controleren: grep "Migratie 17.0.1.1.0" /var/log/odoo/odoo.log Troubleshooting: - Migratiescript draait niet → versie in manifest is niet hoger dan geïnstalleerde versie - Check: SELECT latest_version FROM ir_module_module WHERE name='dispatch_iq_horeca'
# 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,
}
Volgorde in 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.
⚙ Praktijkopdracht H1

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.

H2
Computed fields & constraints
// @api.depends, @api.constrains, data-integriteit
Week 26 · 8u

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.

2.1 @api.depends — afgeleide waarden automatisch herberekenen

@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
      )
✏ Mini-oefening

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
is_high_value = fields.Boolean(compute='_compute_is_high_value', store=True) @api.depends('parts_total') def _compute_is_high_value(self): for order in self: order.is_high_value = order.parts_total > 500
2.2 store=True keuzes — performantie versus realtime berekening

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
)
✏ Mini-oefening

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
Een veld zoals current_client_timezone zou je NIET opslaan met store=True omdat: 1. De tijdzone kan op elk moment wijzigen (zomertijd, reizen) 2. Het is nooit statisch — je wil altijd de actuele waarde 3. Opslaan geeft een vals gevoel van accuraatheid → store=False, berekend elke keer op basis van partner.tz
2.3 @api.constrains — businessregels afdwingen met ValidationError

@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)
        )
✏ Mini-oefening

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
@api.constrains('person_id', 'scheduled_date_start') def _check_max_daily_orders(self): for order in self: if not order.person_id or not order.scheduled_date_start: continue day_start = order.scheduled_date_start.replace(hour=0, minute=0, second=0) day_end = order.scheduled_date_start.replace(hour=23, minute=59, second=59) count = self.env['fsm.order'].search_count([ ('id', '!=', order.id), ('person_id', '=', order.person_id.id), ('scheduled_date_start', '>=', day_start), ('scheduled_date_start', '<=', day_end), ]) if count >= 3: raise ValidationError('Technieker %s heeft al 3 orders op %s.' % ( order.person_id.name, day_start.date()))
2.4 SQL constraints — unieke waarden en dataconsistentie op DB-niveau

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.'
    ),
  ]
✏ Mini-oefening

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
_sql_constraints = [ ('end_after_start', 'CHECK(scheduled_date_end IS NULL OR scheduled_date_start IS NULL OR scheduled_date_end >= scheduled_date_start)', 'Einddatum moet na of gelijk aan startdatum zijn.') ] Verschil: SQL constraint werkt altijd (ook bij directe DB-inserts, imports via psql). Python @api.constrains werkt enkel via ORM maar kan complexere logica uitvoeren (opzoekingen, externe checks). Gebruik beide: SQL voor structurele integriteit, Python voor businesslogica.
2.5 onchange gedrag — gebruikers begeleiden vóór save

@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
✏ Mini-oefening

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
@api.onchange('person_id') def _onchange_person_id(self): if self.person_id and not self.scheduled_date_start: from datetime import datetime, timedelta tomorrow = datetime.now().replace(hour=8, minute=0, second=0) + timedelta(days=1) self.scheduled_date_start = tomorrow Waarom NIET als constraint: onchange is UI-only. Als je via XML-RPC een order aanmaakt zonder scheduled_date_start, triggert onchange niet. Constraints gelden altijd. Onchange is UX-hulp, geen validatie.
2.6 Edge cases — datumoverlap, ontbrekende relaties, lege verplichte velden

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
✏ Mini-oefening

Wat gaat er fout in dit stukje code? order.days_overdue = (fields.Date.today() - order.deadline).days. Schrijf de gecorrigeerde versie.

👁 Toon oplossing
Probleem: order.deadline kan False zijn → je kan geen datetime-aftrekking doen op False → TypeError. Gecorrigeerde versie: if order.deadline: order.days_overdue = (fields.Date.today() - order.deadline).days else: order.days_overdue = 0
2.7 Constraint testing — negatieve tests die blokkering bewijzen

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)
✏ Mini-oefening

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
def test_equipment_overlap_blocked(self): equip = self.env['horeca.equipment'].create({'name': 'Combi-Steamer X', 'serial_no': 'CS001'}) self.env['fsm.order'].create({ 'name': 'FSM/O1/1', 'equipment_id': equip.id, 'scheduled_date_start': '2025-06-01 09:00:00', 'scheduled_date_end': '2025-06-01 12:00:00', }) with self.assertRaises(ValidationError): self.env['fsm.order'].create({ 'name': 'FSM/O2/1', 'equipment_id': equip.id, 'scheduled_date_start': '2025-06-01 11:00:00', # overlap! 'scheduled_date_end': '2025-06-01 14:00:00', })
2.8 Performance bij computes — dependency-ketens beperken

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): ...
✏ Mini-oefening

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
1. Activeer developer mode: Settings → Activate developer mode 2. Open een fsm.order en ga naar het "Technical" menu → Fields 3. Zoek je computed fields en kijk naar hun 'depends' definitie 4. Activeer SQL logging: in odoo.conf zet log_level = debug_sql 5. Wijzig een record en tel het aantal UPDATE-statements in de logs 6. Optimaliseer: flatten dependency-ketens, overweeg store=False voor tussenwaarden die je niet nodig hebt voor search/sort
# 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.')
⚙ Praktijkopdracht H2

Voeg constraints toe zodat een order geen negatieve duur kan hebben en een toestel-serienummer altijd uniek blijft binnen hetzelfde bedrijf.

H3
XML Views & Inheritance
// schermen bouwen en bestaande uitbreiden
Week 27 · 8u

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.

3.1 Form view — <form>, <sheet>, <group>, <notebook>, <page>

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>
✏ Mini-oefening

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
<sheet> <div class="oe_title"> <h1><field name="name" placeholder="Toestellenaam..."/></h1> </div> <group>...</group> </sheet>
3.2 List view — <tree>, optionele kolommen, groepering, totalen

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>
✏ Mini-oefening

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
<tree decoration-danger="warranty_end and warranty_end < current_date"> ... </tree> Andere decoration opties: decoration-warning (oranje), decoration-success (groen), decoration-muted (grijs). current_date is een ingebouwde variabele in Odoo tree views.
3.3 Kanban view — kaartjes per stage

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>
✏ Mini-oefening

Voeg een avatar van de technieker toe op het kanban-kaartje. Gebruik <img t-att-src="..."/> met het Odoo image URL patroon.

👁 Toon oplossing
<field name="person_id"/> <!-- al in fields --> In de kanban-box template: <img t-if="record.person_id.raw_value" t-att-src="'/web/image/fsm.person/' + record.person_id.raw_value + '/image_128'" class="rounded-circle" style="width:32px;height:32px;"/>
3.4 View inheritance — xpath selectoren om bestaande views uit te breiden

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>
✏ Mini-oefening

Wat is het risico van expr="//group[1]" als xpath? Schrijf een betere alternatieve selector.

👁 Toon oplossing
Risico: als de upstream OCA view een group toevoegt of verwijdert, verschuift [1] en pakt je xpath het verkeerde element. Dit breekt bij Odoo-updates. Beter alternatief: gebruik een uniek veld als ankerpunt: expr="//field[@name='scheduled_date_start']" Of een string-attribuut als de group er één heeft: expr="//group[@string='Planning']"
3.5 fsm.order uitbreiden — horeca velden toevoegen zonder OCA te forken

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
  )
✏ Mini-oefening

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
Je module is apart als: 1. Je __manifest__.py vermeldt 'fieldservice' in 'depends' (niet 'name') 2. Je modellen gebruiken _inherit = 'fsm.order' (niet _name) 3. Je views gebruiken inherit_id="fieldservice.view_..." (verwijzing, niet herdefiniëring) 4. Je mapstructuur is addons/dispatch_iq_horeca/ naast addons/fieldservice/ — niet erdoor Bevestiging: installeer fieldservice, verwijder dispatch_iq_horeca → alles werkt nog. Install beide → extra velden verschijnen.
3.6 Menu items — ir.ui.menu en jouw module in het FieldService menu

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"/>
✏ Mini-oefening

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
<menuitem id="menu_horeca_equipment" name="Horeca Toestellen" parent="fieldservice.menu_fieldservice_master" action="action_horeca_equipment" groups="dispatch_iq_horeca.group_dispatcher,base.group_system" sequence="20"/> Het groups-attribuut accepteert een komma-gescheiden lijst van external IDs. Gebruikers buiten deze groepen zien het menu item simpelweg niet.
3.7 Domain en context — default waarden en smart buttons

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',
  }
✏ Mini-oefening

Hoe schrijf je het domain om enkel orders in de stage "Afgerond" te tonen in het smart button venster?

👁 Toon oplossing
'domain': [ ('equipment_id', '=', self.id), ('stage_id.name', '=', 'Afgerond'), ], Of beter via de stage's is_closed vlag (stabieler dan naam): ('stage_id.is_closed', '=', True)
3.8 UX guardrails — readonly/attrs per stage

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"/>
✏ Mini-oefening

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
<field name="cancellation_reason" attrs="{'invisible': ['|', ('stage_id.is_closed', '!=', True), ('kanban_state', '!=', 'blocked') ]}" /> Let op: in Odoo domain syntax is AND impliciet (lijstelementen), voor OR gebruik je '|' als prefix operator.
3.9 Upgrade-safe xpath — selectoren bestand tegen upstream wijzigingen

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 -->
✏ Mini-oefening

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
1. Open de OCA module source: addons/fieldservice/views/fsm_order_views.xml 2. Zoek het element dat je xpath targette — bestaat het nog? Heeft het dezelfde naam/string? 3. Pas ALLEEN jouw inherit view aan in dispatch_iq_horeca/views/ 4. Update: ./odoo-bin -u dispatch_iq_horeca -d dispatch_db 5. Hertest alle views in de UI Gouden regel: NOOIT de OCA bestanden aanpassen. Altijd jouw xpath aanpassen.
<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>
⚙ Praktijkopdracht H3

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.

H4
QWeb PDF Werkbon
// professionele PDF rapporten genereren
Week 28 · 8u

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.

4.1 QWeb template engine — t-if, t-foreach, t-field, t-esc, t-raw

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>
✏ Mini-oefening

Wat is het verschil tussen t-esc en t-raw? Wanneer gebruik je t-raw en waarom is dat risicovol?

👁 Toon oplossing
t-esc: converteert <, >, & naar HTML-entities → veilig, geen XSS risico t-raw: plaatst de string letterlijk als HTML → gevaarlijk als de waarde user input bevat t-raw gebruik je enkel voor: - Pre-rendered HTML uit vertrouwde Odoo-velden (bijv. mail.body) - HTML die je zelf in Python hebt geconstrueerd en die gegarandeerd geen user input bevat Nooit t-raw op: o.name, o.description, of andere door gebruikers ingevulde tekstvelden.
4.2 ir.actions.report — rapportdefinitie koppelen aan model en knop

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>
✏ Mini-oefening

Wat is het verschil tussen report_type="qweb-pdf" en report_type="qweb-html"? Wanneer gebruik je welke?

👁 Toon oplossing
qweb-pdf: renders via wkhtmltopdf → download als .pdf bestand qweb-html: renders in browser als HTML pagina Gebruik qweb-pdf voor: werkbonnen, facturen, certificaten, alles wat afgedrukt/opgeslagen wordt Gebruik qweb-html voor: preview in browser, debugging tijdens ontwikkeling (sneller dan PDF render)
4.3 Werkbon layout — bedrijfslogo, klantgegevens, onderdelen, handtekeningveld

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>
✏ Mini-oefening

Voeg een subtotaal-, BTW- en totaalrij toe onderaan de onderdelen-tabel. BTW is 21%. Gebruik Python-expressies in t-esc.

👁 Toon oplossing
<tfoot> <tr> <td colspan="3"><strong>Subtotaal ex BTW</strong></td> <td class="text-right"><t t-esc="'%.2f' % o.parts_total"/></td> </tr> <tr> <td colspan="3">BTW 21%</td> <td class="text-right"><t t-esc="'%.2f' % (o.parts_total * 0.21)"/></td> </tr> <tr> <td colspan="3"><strong>Totaal incl. BTW</strong></td> <td class="text-right"><strong><t t-esc="'%.2f' % (o.parts_total * 1.21)"/></strong></td> </tr> </tfoot>
4.4 Wkhtmltopdf — HTML naar PDF, pagina-einden en headers

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>
✏ Mini-oefening

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
1. Render als HTML eerste: verander report_type tijdelijk naar qweb-html → als het logo daar ook ontbreekt, is het een QWeb templatefout (verkeerd img src of ontbrekend t-att-src) 2. Controleer de img src URL: wkhtmltopdf heeft geen ingelogde sessie → het kan geen /web/image/ URLs laden die authenticatie vereisen. Gebruik data URI (base64) voor logo's: <img t-att-src="'data:image/png;base64,' + company.logo.decode()"/>
4.5 Digitale handtekening — Binary veld, Base64 afbeelding in PDF

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>
✏ Mini-oefening

Hoe verhinder je dat de handtekening gewist wordt nadat een order "Afgerond" is? Schrijf de ORM-override.

👁 Toon oplossing
def write(self, vals): if 'signature' in vals: for order in self: if order.stage_id.is_closed and not vals['signature']: raise ValidationError( 'Handtekening kan niet gewist worden op een afgesloten order.' ) return super().write(vals)
4.6 PDF opslaan als bijlage — ir.attachment via res_model + res_id

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',
    })
✏ Mini-oefening

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
// Node.js XML-RPC const attachments = await models.execute_kw( db, uid, password, 'ir.attachment', 'search_read', [[ ['res_model', '=', 'fsm.order'], ['res_id', '=', orderId], ['mimetype', '=', 'application/pdf'], ]], { fields: ['name', 'datas', 'create_date'], limit: 10 } ); // attachments[0].datas is base64-encoded PDF const pdfBytes = Buffer.from(attachments[0].datas, 'base64');
4.7 Rapportversies — templateversie registreren voor audit

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>
✏ Mini-oefening

Waarom is het onvoldoende om gewoon de QWeb-template op te slaan in git voor "historische" werkbonnen? Wat is het echte probleem?

👁 Toon oplossing
Het echte probleem: zelfs als je de template kan terugvinden in git, zijn de DATAGEGEVENS misschien al gewijzigd. Voorbeeld: de prijs van een onderdeel is achteraf gecorrigeerd in Odoo. Als je de oude template opnieuw rendert met de huidige data, toont de PDF een andere prijs dan wat de klant oorspronkelijk gekregen heeft. Oplossing: sla ALTIJD de gegenereerde PDF-bytes op als bijlage bij de order op het moment van afsluiting. De PDF is het enige juridisch correcte document — niet de herrenderde template.
4.8 Print performance — zware tabellen pagineren zonder timeout

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; }
✏ Mini-oefening

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
In odoo.conf: limit_time_real = 120 # default 60 seconden, verhoog naar 120-300 Voor specifieke wkhtmltopdf timeout, in Python: # De timeout zit in ir.actions.report._run_wkhtmltopdf() # Je kan de wkhtmltopdf options meegeven via report paperformat Betere aanpak: optimaliseer eerst de query (preload data) voordat je de timeout verhoogt. Gebruik ook: ./odoo-bin --test-tags=... met profiling om de trage query te vinden.
<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>
⚙ Praktijkopdracht H4

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.

H5
Skills module & wizards
// eigen skills datamodel en begeleide acties
Week 29 · 8u

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.

5.1 dispatch.skill model — skillcatalogus per discipline

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')
✏ Mini-oefening

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
<!-- data/dispatch_skill_data.xml --> <odoo> <record id="skill_fgas" model="dispatch.skill"> <field name="name">F-gas attest</field> <field name="category">refrigeration</field> <field name="validity_months">36</field> <field name="requires_certificate">True</field> </record> <record id="skill_haccp" model="dispatch.skill"> <field name="name">HACCP basisopleiding</field> <field name="category">hygiene</field> <field name="validity_months">12</field> </record> <record id="skill_lez" model="dispatch.skill"> <field name="name">LEZ-rijbewijs</field> <field name="category">driving</field> <field name="validity_months">60</field> </record> </odoo>
5.2 Technieker-skill mapping — niveau, geldigheid, certificaatdatum

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
✏ Mini-oefening

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
@api.depends('expiry_date') def _compute_is_valid(self): today = fields.Date.today() for rec in self: if not rec.expiry_date: rec.is_valid = True # permanente skill, nooit vervalt else: rec.is_valid = rec.expiry_date >= today
5.3 Transient models — wizard stappen en tijdelijke data

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 = ''
✏ Mini-oefening

Wanneer is een TransientModel NIET de juiste keuze? Geef een concreet DispatchIQ-voorbeeld waarbij je beter een gewoon Model gebruikt.

👁 Toon oplossing
TransientModel is NIET geschikt als de data bewaard moet blijven: Slecht voorbeeld als TransientModel: "Herschikking log" — een overzicht van alle keren dat een dispatcher een technieker heeft gewijzigd. Dit moet permanent bewaard worden voor audit en rapportage. Gebruik dan een gewoon Model: class DispatchReassignmentLog(models.Model): _name = 'dispatch.reassignment.log' order_id = fields.Many2one('fsm.order') old_person_id = fields.Many2one('fsm.person') new_person_id = fields.Many2one('fsm.person') reason = fields.Text() user_id = fields.Many2one('res.users', default=lambda s: s.env.uid)
5.4 Bulk acties — meerdere orders tegelijk herplannen

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'}
✏ Mini-oefening

Hoe koppel je deze bulk-wizard aan een knop in de list view van fsm.order? Schrijf de XML actiedefinitie.

👁 Toon oplossing
<!-- Als server action, zichtbaar als actieknop in list view --> <record id="action_bulk_reschedule_wizard" model="ir.actions.act_window"> <field name="name">Herplannen</field> <field name="res_model">dispatch.bulk.reschedule.wizard</field> <field name="view_mode">form</field> <field name="target">new</field> <!-- popup --> <field name="binding_model_id" ref="fieldservice.model_fsm_order"/> <field name="binding_type">action</field> </record> Na installatie: in de fsm.order list view, selecteer records → Actie dropdown → "Herplannen"
5.5 Wizard validaties — gebruikersfouten vroeg blokkeren

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'}
✏ Mini-oefening

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
Gebruik een computed veld 'missing_skills' dat herberekent via @api.depends op 'technician_id': missing_skills = fields.Text(compute='_compute_missing_skills') @api.depends('technician_id', 'order_id') def _compute_missing_skills(self): for w in self: if w.technician_id and w.order_id: missing = w.order_id.check_technician_skills(w.technician_id.id) w.missing_skills = ', '.join(missing) if missing else 'Alle skills aanwezig ✓' In de wizard form view: <field name="missing_skills" readonly="1" attrs="{'invisible': [('technician_id', '=', False)]}"/>
5.6 UI integratie — knoppen op form/list voor snelle dispatcherflow

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)]}"/>
✏ Mini-oefening

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
Vervang act_window_close door een actie die de order opnieuw opent: def action_confirm(self): # ... valideer en wijs toe ... return { 'type': 'ir.actions.act_window', 'res_model': 'fsm.order', 'res_id': self.order_id.id, 'view_mode': 'form', 'target': 'current', # vervang de huidige view } Dit sluit de wizard EN toont de bijgewerkte order form in één stap.
5.7 Fallback suggesties — alternatieve techniekers gerangschikt op skills

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
✏ Mini-oefening

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
In het wizard model: suggested_technician_ids = fields.Many2many( 'fsm.person', compute='_compute_suggested_technicians', string='Beschikbare alternatieven' ) @api.depends('order_id', 'order_id.scheduled_date_start') def _compute_suggested_technicians(self): for w in self: w.suggested_technician_ids = [(6, 0, w._get_suggested_technicians())] In de wizard form XML: <field name="suggested_technician_ids" readonly="1" widget="many2many_tags" attrs="{'invisible': [('suggested_technician_ids', '=', [])]}"> <tree><field name="name"/></tree> </field>
5.8 Auditbare keuzes — wizardbeslissingen loggen voor analyse

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),
})
✏ Mini-oefening

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
Domain: [ ('had_skill_override', '=', True), ('create_date', '>=', (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')), ] Als management dashboard: voeg dit domain toe aan een ir.actions.act_window: <field name="domain">[('had_skill_override','=',True),('create_date','>=', (datetime.now()-timedelta(days=30)).strftime('%Y-%m-%d'))]</field> Of maak een pivot view met groupby op 'assigned_by' om te zien welke dispatcher de meeste overrides doet → inzicht voor coaching en opleiding.
# 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
⚙ Praktijkopdracht H5

Bouw een wizard die bij het toewijzen van een order onmiddellijk controleert op ontbrekende skills en alternatieve techniekers voorstelt.

H6
Security & tests
// access rules, record rules, TransactionCase
Week 30 · 8u

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.

6.1 ir.model.access.csv — access rechten per model per groep

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
✏ Mini-oefening

Na installatie krijg je "Access Denied" op dispatch.assign.wizard. Wat ontbreekt er en hoe fix je het?

👁 Toon oplossing
Het transient model heeft ook een access rule nodig! Voeg toe aan ir.model.access.csv: access_dispatch_assign_wizard,dispatch.assign.wizard,model_dispatch_assign_wizard,dispatch_iq_horeca.group_dispatcher,1,1,1,1 Let op: voor TransientModels geef je doorgaans 1,1,1,1 omdat de data tijdelijk is en de gebruiker zijn eigen wizard-record moet kunnen schrijven/verwijderen.
6.2 Record rules — technieker ziet enkel eigen orders

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>
✏ Mini-oefening

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
<record id="rule_fsm_order_company" model="ir.rule"> <field name="name">FSM Order: multi-company isolatie</field> <field name="model_id" ref="fieldservice.model_fsm_order"/> <field name="global" eval="True"/> <!-- voor alle gebruikers --> <field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field> </record> company_ids is een speciale variabele in ir.rule die de actieve bedrijven van de gebruiker bevat. 'company_id = False' dekt records zonder bedrijf (globale records).
6.3 Security regressies — testscenario's voor rollen en datalekken

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()
✏ Mini-oefening

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
def test_technician_cannot_write_cost_price(self): order = self.env['fsm.order'].create({'name': 'FSM/COST/1'}) with self.assertRaises(AccessError): order.with_user(self.jonas).write({'parts_cost_price': 99.99}) def test_dispatcher_can_write_cost_price(self): group_disp = self.env.ref('dispatch_iq_horeca.group_dispatcher') dispatcher = self.env['res.users'].create({ 'name': 'Dispatcher', 'login': 'disp_cost', 'groups_id': [(4, group_disp.id)] }) order = self.env['fsm.order'].create({'name': 'FSM/COST/2'}) order.with_user(dispatcher).write({'parts_cost_price': 50.0}) # geen fout self.assertEqual(order.parts_cost_price, 50.0)
6.4 TransactionCase tests — modelgedrag en constraints valideren

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!
      })
✏ Mini-oefening

Hoe draai je alleen de tests van jouw module zonder alle andere Odoo tests? Geef het CLI commando.

👁 Toon oplossing
./odoo-bin --test-enable --test-tags=dispatch_iq_horeca -d dispatch_db -u dispatch_iq_horeca Uitleg: --test-enable: activeert het testframework --test-tags=dispatch_iq_horeca: enkel tests van deze module -d dispatch_db: de testdatabase -u dispatch_iq_horeca: update de module eerst (laadt nieuwe code) Voor specifieke testklasse: --test-tags=dispatch_iq_horeca.TestFsmOrderBusiness Voor specifieke testmethode: --test-tags=dispatch_iq_horeca.TestFsmOrderBusiness.test_parts_total_computed_correctly
6.5 HttpCase tests — basis e2e checks op controllers en views

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"
    )
✏ Mini-oefening

Wat is het nadeel van HttpCase tests ten opzichte van TransactionCase, en wanneer kies je toch voor HttpCase?

👁 Toon oplossing
Nadelen HttpCase: - 5-20x trager (start HTTP server, browser simulatie) - Moeilijker te debuggen (stacktrace minder duidelijk) - Vereist correct opgestarte Odoo instantie (met modules geladen) - Browser tours zijn fragiel bij UI-wijzigingen Kies toch voor HttpCase: - Testen van HTTP response codes en headers (PDF download, redirect na login) - Testen dat een view laadt zonder 500-error (template rendering) - Smoke test na deployment: "werkt de app nog überhaupt?" - Testen van JavaScript-afhankelijke UI flows via tours
6.6 CI pipeline — tests draaien bij elke commit

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
✏ Mini-oefening

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
Odoo geeft exit code 0 zelfs bij gefaalde tests, tenzij je --stop-after-init gebruikt én de logs parseert. Betere aanpak: grep de logs op "FAILED" of "ERROR": odoo ... --stop-after-init 2>&1 | tee odoo_test.log grep -E "FAILED|ERROR" odoo_test.log && exit 1 || exit 0 Of gebruik de --test-tags met logfile en check exit code: Odoo 16+ geeft exit code 1 als tests falen wanneer je --test-enable en --stop-after-init combineert. Tip: voeg ook toe aan de pipeline: grep "tests failed" odoo_test.log && exit 1
6.7 Security smoke suite — privilege escalation en ongewenste exports

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([])
✏ Mini-oefening

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
class TestSecuritySmoke(HttpCase): def test_technician_cannot_export_assignment_log(self): group_tech = self.env.ref('dispatch_iq_horeca.group_technician') tech_user = self.env['res.users'].create({ 'name': 'Jonas HTTP', 'login': 'j_http', 'password': 'test_pass_123', 'groups_id': [(4, group_tech.id)] }) # Authenticeer als technieker self.authenticate('j_http', 'test_pass_123') response = self.url_open('/web/dataset/call_kw', data=json.dumps({ 'model': 'dispatch.assignment.log', 'method': 'search_read', 'args': [[]], 'kwargs': {} }), headers={'Content-Type': 'application/json'}) data = response.json() self.assertIn('error', data) # moet een AccessError teruggeven
6.8 Release checklist — versie bump, migratienota's en rollbackplan

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
✏ Mini-oefening

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
Optie 1: geef een default waarde via SQL (snelst): def migrate(cr, version): cr.execute(""" ALTER TABLE horeca_equipment ADD COLUMN IF NOT EXISTS installation_year INTEGER DEFAULT 2020 """) Optie 2: voeg required=False toe aan het veld eerst, deploy, vul data in, dan required=True (veiliger voor productie) Optie 3: gebruik default in het veld zelf: installation_year = fields.Integer(default=2020, required=True) → Odoo vult automatisch 2020 in voor bestaande records bij -u Gouden regel: voeg nooit een required veld toe zonder default of migratiescript in een productieomgeving.
# 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)
⚙ Deliverable Vak 5

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.

Eindproject · Weken 1–36 · Parallel aan alle vakken

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.

6
Sprints
2
Buismomenten
1
Verdediging
Slaagvereiste — niet onderhandelbaar: DispatchIQ draait in productie op HTTPS. Gantt werkt met echte Odoo data. Minstens 3 constraints werkend (parts, skills, LEZ). Terugschrijven naar Odoo werkend. Live technieker timer werkend. Code staat op GitHub met README. Mondeling: leg elke architecturele keuze uit.
Vereiste functionaliteit
Wat DispatchIQ moet kunnen
Frontend — Dispatcher View
  • 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
Frontend — Technieker PWA
  • 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)
Backend — API Proxy
  • 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)
Odoo — Data & Module
  • 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
Constraints Engine
  • Parts check (stock.quant vs voertuig)
  • Skills check (dispatch.technician.skill)
  • Site access check
  • LEZ/rijzone check
  • Dubbele boeking blokkeren
  • Smart suggest alternatief
Infrastructuur
  • Docker Compose volledige stack
  • Traefik HTTPS automatisch
  • OpenRouteService met Belgium OSM
  • GitHub repo met README
  • Deployment script (1 commando)
  • Backup strategie voor Odoo DB
Beoordeling
Puntenverdeling
OnderdeelGewichtBuis 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
Tijdlijn
Sprint milestones
Wk 6
Sprint 1
Gantt UI + gesimuleerde data
Sidebar, Gantt, drag & drop, pauzeblok. Data uit lokale JSON. Draait op Hetzner.
Demo
Wk 12
Sprint 2
API proxy + ORS reistijd
Node.js proxy, ORS matrix calls, echte reistijdblokken in Gantt.
Demo
Wk 18
🔴 BUIS 1
Echte Odoo data in Gantt
fsm.order records zichtbaar. Toewijzing schrijft terug naar Odoo. WebSocket live update.
BUISLIJN
Wk 24
Sprint 4
Constraints engine live
Parts check, skills check, LEZ check werkend. Smart suggest alternatief.
Demo
Wk 30
🔴 BUIS 2
Odoo module geïnstalleerd
dispatch_iq_horeca werkend in Odoo. Werkbon PDF genereerbaar. Skills module actief.
BUISLIJN
Wk 34
Sprint 6
Technieker PWA live
Aankomst timer, checklist, handtekening, foto upload, offline capable.
Demo
Wk 36
Verdediging
Finale presentatie + mondeling
Live demo van volledige flow: vaststelling → inplannen met constraint check → technieker ter plaatse → werkbon PDF → factuur in Odoo. Gevolgd door 45min mondeling.
VERDEDIGING
Acceptatie
Go/No-Go Gates
GateMinimum bewijsNo-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
Verdediging
Bewijs dat je moet kunnen tonen
Live bewijs
  • 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
Technisch bewijs
  • 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
Mondeling bewijs
  • 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
Verificatie
Testmatrix (Pass/Fail)
ScenarioStapVerwacht 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
Operaties
Deployment & Incident Checklist
Pre-Deploy
  • 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
Deploy
  • 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
Post-Deploy & Incident
  • 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
Waarom dit werkt: Elk vak bouwt direct een stuk van het eindproject. Na Vak 1 heb je de Gantt. Na Vak 2 de API. Na Vak 3 de Odoo setup. Na Vak 4 de constraints. Na Vak 5 de module. Op week 36 is het eindproject de som van alles wat je geleerd hebt — geen aparte opdracht bovenop de vakken.