/*! * Tectum * Copyright © 2026 Machiel Heinen. * Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming. * Zie LICENTIE.md. */ // ============================================================ // cost/cost-model.js — CostModel: ENIGE bron van de kostenberekening // (materiaal + arbeid). Geen DOM. Rekent op de doorgerekende woning // (HouseModel.computeParts + computeTotals) met de kosten/tijden uit // COST_DEFAULTS (cost-defaults.js) plus de eigen instellingen van de // gebruiker (localStorage, instellingenvenster cost-settings.js). // // Opbouw: per element (wandstuk, vloer, dak, woning-breed) regels // { cat, act, label, qty, unit, mat (€), sec (s arbeid) } // cat = materiaalgroep, act = werkzaamheid. Opgeteld per element, per unit // en voor de woning; daarna opslagen (indirecte uren, materiaal, marge, btw). // // Plain script, geladen na house-model.js en cost-defaults.js. // ============================================================ window.CostModel = (() => { const STORAGE_KEY = 'tectum.costs'; const listeners = []; // ------------------------------------------------------------ // Instellingen: standaard (COST_DEFAULTS) + eigen waarden (localStorage). // values = { groep: { sleutel: waarde } }. // ------------------------------------------------------------ function defaultValues() { const v = {}; Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => { v[g] = {}; Object.entries(grp.items).forEach(([k, it]) => { v[g][k] = it.value; }); }); return v; } // Alleen bekende sleutels met een geldig type overnemen. function mergeValues(base, over) { const v = JSON.parse(JSON.stringify(base)); Object.entries(over || {}).forEach(([g, items]) => { const grp = window.COST_DEFAULTS[g]; if (!grp || !items || typeof items !== 'object') return; Object.entries(items).forEach(([k, val]) => { const it = grp.items[k]; if (!it) return; if (it.options ? Object.prototype.hasOwnProperty.call(it.options, val) : typeof val === 'number' && isFinite(val)) v[g][k] = val; }); }); return v; } function readOverrides() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; } catch (e) { return {}; } } function getSettings() { return mergeValues(defaultValues(), readOverrides()); } // Slaat alleen de afwijkingen van de standaard op. function saveSettings(values) { const def = defaultValues(), diff = {}; Object.entries(mergeValues(def, values)).forEach(([g, items]) => Object.entries(items).forEach(([k, val]) => { if (val !== def[g][k]) (diff[g] = diff[g] || {})[k] = val; })); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(diff)); } catch (e) { /* geen opslag: alleen deze sessie */ } listeners.forEach(fn => fn()); } function resetSettings() { saveSettings(defaultValues()); } function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; } // Export/import (JSON-bestand): altijd de volledige set waarden. function exportJson(values) { return { type: 'tectum-costs', version: 1, exportedAt: new Date().toISOString(), values: mergeValues(defaultValues(), values) }; } function importJson(obj) { if (!obj || obj.type !== 'tectum-costs' || typeof obj.values !== 'object') throw new Error('geen Tectum-kostenbestand'); return mergeValues(defaultValues(), obj.values); } // ------------------------------------------------------------ // Berekening // ------------------------------------------------------------ const CAT_ORDER = ['Hout', 'Schroeven', 'Plaatwerk', 'Isolatie', 'Gevel', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting']; const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting']; const VERTICAL_KINDS = ['verticale balk', 'kreupele stijl']; const DORPEL_KINDS = ['bovendorpel', 'onderdorpel']; function compute(parts, totals, settings) { const S = settings || getSettings(); const elements = []; const newEl = (element, label, unit) => { const el = { element, label, unit, lines: [] }; elements.push(el); return el; }; // sec = tijd voor één persoon; crew = personeel bij die stap (standaard // de gemiddelde bezetting per productiestap). Opgeslagen sec = manuren. const add = (el, cat, act, label, qty, unit, mat, sec, crew) => { if (!(qty > 0)) return; const c = sec ? (crew !== undefined ? crew : S.arbeid.bezetting) : 0; // clock = doorlooptijd van de stap (s), voor afschrijving/huisvesting. el.lines.push({ cat, act, label, qty, unit, mat: mat || 0, sec: (sec || 0) * c, clock: c ? sec : 0, crew: c }); }; // --- Gedeelde rekenregels --- const Z = S.zagen, V = S.verbinden, P = S.plaat; const sawFactor = Z.methode === 'hand' ? Z.handFactor : 1; // Zaagtijd van één stuk (s): handling + sneden (+ extra per schuine snede). // saw: totalen voor het zaagoverzicht (robot- en handtijd, los van de keuze). const saw = { pieces: 0, cuts: 0, angled: 0, robotSec: 0 }; const sawSec = (cutAngles, count = 1) => { const ca = cutAngles || [0, 0], angled = ca.filter(a => a !== 0).length; const robot = Z.robotStuk + ca.length * Z.robotSnede + angled * Z.robotSchuin; saw.pieces += count; saw.cuts += count * ca.length; saw.angled += count * angled; saw.robotSec += count * robot; return count * robot * sawFactor; }; const sawLabel = Z.methode === 'hand' ? 'Zagen (handmatig)' : 'Zagen (robot)'; const jointSec = V.eersteSchroef + Math.max(0, V.schroevenPerVerbinding - 1) * V.volgendeSchroef; // Constructie monteren (lijmen + schroeven): schroeftijd x V.monteerFactor, // met V.personeel man (manuren), op het dak factor V.dakFactor (roof = true). const addJoints = (el, joints, label, roof) => { const n = joints * V.schroevenPerVerbinding; const f = V.monteerFactor * (roof ? V.dakFactor : 1); add(el, 'Schroeven', 'Regelwerk monteren', `${label} monteren, lijmen + schroeven (${joints} verb. × ${V.schroevenPerVerbinding}, ×${V.monteerFactor}${roof ? `, dak ×${V.dakFactor}` : ''})`, n, 'st.', n * V.prijsPer1000 / 1000, joints * jointSec * f, V.personeel); }; // Plaat: materiaal + montage + plaatschroeven. const addSheet = (el, cat, label, m2, priceM2, montageSecM2) => { if (!(m2 > 0)) return; add(el, cat, 'Plaatwerk', label, m2, 'm²', m2 * priceM2, m2 * montageSecM2); const n = Math.ceil(m2 * P.schroevenPerM2); add(el, 'Schroeven', 'Plaatwerk', `Plaatschroeven ${label.toLowerCase()}`, n, 'st.', n * P.plaatschroefPer1000 / 1000, n * P.tijdPerPlaatschroef); }; // Snijverlies wandbalken (inkoop in handelslengtes, hele woning). const waste = totals.purchaseStockMM > 0 ? totals.purchaseWasteMM / Math.max(1, totals.purchaseStockMM - totals.purchaseWasteMM) : 0; const fmtM = (v) => v.toLocaleString('nl-NL', { maximumFractionDigits: 1 }); // --- Wandstukken (buiten + binnen/naad) --- const seenOpenings = new Set(); // naadwand: openingen één keer (beide delen hebben dezelfde deur) function wallElement(p, unitId) { const el = newEl(p.element, p.label, unitId); const bom = WallModel.computeBOM(p.config, p.frame); const woodM3 = bom.reduce((a, b) => a + b.width * b.depth * b.length, 0) / 1e9; add(el, 'Hout', 'Zagen', `Regelwerk ${bom.length} st. (incl. ${Math.round(waste * 100)}% snijverlies)`, woodM3 * (1 + waste), 'm³', woodM3 * (1 + waste) * S.hout.constructieM3, 0); const cuts = bom.reduce((a, b) => a + (b.cutAngles || [0, 0]).length, 0); add(el, 'Hout', 'Zagen', `${sawLabel}: ${bom.length} stuks`, cuts, 'sneden', 0, bom.reduce((a, b) => a + sawSec(b.cutAngles), 0)); const verticals = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).length; const dorpels = bom.filter(b => DORPEL_KINDS.includes(b.kind)).length; const topPlates = bom.filter(b => b.kind === 'bovenregel').length; addJoints(el, 2 * verticals + 2 * dorpels + Math.max(0, topPlates - 1), 'Stijlen/dorpels'); // Dubbele stijlen (deur, hoek: twee stijlen tegen elkaar): verticaal // elke V.dubbelAfstand mm een schroef over de gezamenlijke lengte. const studs = bom.filter(b => VERTICAL_KINDS.includes(b.kind)).sort((a, b) => a.x - b.x); let dblScrews = 0, dblPairs = 0; for (let i = 1; i < studs.length; i++) { const a = studs[i - 1], b = studs[i]; if (b.x - a.x > (a.width + b.width) / 2 + 1) continue; const ov = Math.min(a.y + a.length / 2, b.y + b.length / 2) - Math.max(a.y - a.length / 2, b.y - b.length / 2); if (ov <= 0) continue; dblPairs++; dblScrews += Math.max(2, Math.floor(ov / V.dubbelAfstand)); } add(el, 'Schroeven', 'Regelwerk monteren', `Dubbele stijlen ${dblPairs}× (schroef elke ${V.dubbelAfstand} mm, ×${V.monteerFactor})`, dblScrews, 'st.', dblScrews * V.prijsPer1000 / 1000, dblScrews * V.volgendeSchroef * V.monteerFactor, V.personeel); const M = S.montage; add(el, 'Hout', 'Wanden plaatsen', `Plaatsen en vastzetten (${M.muurVastzetten} min)`, 1, 'sectie', 0, M.muurVastzetten * 60, M.muurPersoneel); const st = p.stats || {}; addSheet(el, 'Plaatwerk', 'Spaanplaat', (st.spaanplaatAreaM2 || 0) + (st.spaanplaatBuitenAreaM2 || 0), P.spaanplaatM2, P.spaanplaatMontage); addSheet(el, 'Plaatwerk', 'Cementplaat', (st.cementplaatAreaM2 || 0) + (st.trasraamAreaM2 || 0), P.cementplaatM2, P.cementplaatMontage); const I = S.isolatie; const wool = (p.specs && p.specs.voidVolumeM3) || 0; add(el, 'Isolatie', 'Isoleren', 'Glaswol', wool, 'm³', wool * I.glaswolM3, wool * I.glaswolMontage); const stoneIso = (st.stoneIsolatieM3 || 0) + (st.trasraamIsolatieM3 || 0); add(el, 'Isolatie', 'Gevel', 'Isolatieblok steen', stoneIso, 'm³', stoneIso * I.steenIsolatieM3, 0); const G = S.gevel; const doek = st.doekAreaM2 || 0; // Montage per strekkende meter wand (breedte van het stuk). const doekM = doek > 0 ? p.place.width / 1000 : 0; add(el, 'Gevel', 'Gevel', `Vochtdoek (${fmtM(doekM)} m¹ wand)`, doek, 'm²', doek * G.vochtdoekM2, doekM * G.vochtdoekMontageM); // Rabat over het doek, boven het trasraam (gevelvlak uit WallModel). const rabat = p.buitenMateriaal === 'vochtdoek' ? (st.rabatAreaM2 || 0) : 0; add(el, 'Gevel', 'Gevel', 'Rabat', rabat, 'm²', rabat * G.rabatM2, rabat * G.rabatMontage); const steen = (st.stoneAreaM2 || 0) + (st.trasraamStoneAreaM2 || 0); add(el, 'Gevel', 'Gevel', 'Steenstrips', steen, 'm²', steen * G.steenM2, steen * G.steenMontage); const latM = st.battenLengthM || 0; add(el, 'Hout', 'Latwerk', `Latwerk (${st.battenCount || 0} latten inkoop, ${st.battenPieces || 0} stukken)`, latM, 'm', latM * S.hout.latwerkM, (st.battenPieces || 0) * S.hout.latStukMontage + latM * S.hout.latMeterMontage); const K = S.kozijnen; const opKey = p.seam ? `iw${p.id}` : null; if (!opKey || !seenOpenings.has(opKey)) { if (opKey) seenOpenings.add(opKey); (p.config.openings || []).forEach(op => { if (op.type === 'raam') { const m2 = WallModel.openingArea(op) / 1e6; add(el, 'Kozijnen', 'Kozijnen', `Raam ${op.w}×${op.h}`, m2, 'm²', m2 * K.raamM2, K.montage); } else { add(el, 'Kozijnen', 'Kozijnen', `${op.type === 'voordeur' ? 'Voordeur' : 'Deur'} ${op.w}×${op.h}`, 1, 'st.', op.type === 'voordeur' ? K.voordeur : K.deur, K.montage); } }); } } parts.units.forEach(u => u.walls.forEach(w => w.pieces.forEach(p => wallElement(p, u.id)))); parts.interior.forEach(p => wallElement(p, p.seamUnit || null)); // --- Vloer- en dakbalken (balklaag: zagen + verbindingen) --- function joistLayer(el, cd, angleDeg, what, roof) { const rims = ['front', 'back'].filter(k => cd.rims && cd.rims[k]).length; const n = cd.joists.length; const ang = angleDeg || 0; add(el, 'Hout', 'Zagen', `${sawLabel}: ${what}balken ${n} + kopbalken ${rims}`, 2 * (n + rims), 'sneden', 0, sawSec([ang, ang], n) + sawSec([0, 0], rims)); addJoints(el, n * V.verbindingenPerVloerbalk, `${what}balken`, roof); } parts.units.forEach(u => { // Vloer const fl = newEl(u.floorElement, `Unit ${u.id} vloer`, u.id); const fs = u.floorSpecs; add(fl, 'Hout', 'Zagen', 'Vloerbalken', fs.joistWoodM3, 'm³', fs.joistWoodM3 * S.hout.constructieM3, 0); joistLayer(fl, u.floorData.calcData, 0, 'Vloer'); addSheet(fl, 'Plaatwerk', 'OSB vloer', fs.osbAreaM2, P.osbM2, P.osbMontage); add(fl, 'Isolatie', 'Isoleren', 'Glaswol vloer', fs.voidM3, 'm³', fs.voidM3 * S.isolatie.glaswolM3, fs.voidM3 * S.isolatie.glaswolMontage); // Afwerking: vloer min de voetafdruk van de wanden op deze unit. const foot = u.walls.reduce((a, w) => a + w.pieces.filter(p => !p.place.baseY).reduce((b, p) => b + p.place.width * p.place.thickness, 0), 0) + parts.interior.filter(p => p.seamUnit === u.id).reduce((a, p) => a + p.place.width * p.place.thickness, 0); const finM2 = Math.max(0, u.rect.W * u.rect.D - foot) / 1e6; add(fl, 'Vloer', 'Vloer', 'Vloerafwerking', finM2, 'm²', finM2 * S.vloer.afwerkingM2, finM2 * S.vloer.afwerkingMontage); // Dak const rf = newEl(`${u.id}-D`, `Unit ${u.id} dak`, u.id); const rs = u.roof.specs, D = S.dak; add(rf, 'Hout', 'Zagen', 'Dakbalken', rs.joistWoodM3, 'm³', rs.joistWoodM3 * S.hout.constructieM3, 0); u.roof.planes.forEach((pl, i) => joistLayer(rf, pl.data.calcData, Math.round(Math.abs(pl.theta || 0) * 180 / Math.PI * 10) / 10, u.roof.planes.length > 1 ? `Dak ${i + 1} ` : 'Dak', true)); // Wolfsdak: bovenste deel (sporen, hoekkepers, nokbalk, kopbalken). const cap = u.roof.unitRoof && u.roof.unitRoof.cap; if (cap) { const n = cap.beams.length; add(rf, 'Hout', 'Zagen', `${sawLabel}: wolfseinden ${n} balken`, 2 * n, 'sneden', 0, cap.beams.reduce((t, bm) => t + sawSec([bm.angle, bm.angle], 1), 0)); addJoints(rf, n * V.verbindingenPerVloerbalk, 'wolfseindbalken', true); } addSheet(rf, 'Plaatwerk', 'OSB plafond', rs.osbAreaM2, P.osbM2, P.osbMontage); add(rf, 'Isolatie', 'Isoleren', 'Glaswol dak', rs.voidM3, 'm³', rs.voidM3 * S.isolatie.glaswolM3, rs.voidM3 * S.isolatie.glaswolMontage); add(rf, 'Dak', 'Dak', 'Dakleer (EPDM)', rs.dakleerM2 || 0, 'm²', (rs.dakleerM2 || 0) * D.dakleerM2, (rs.dakleerM2 || 0) * D.dakleerMontage); add(rf, 'Dak', 'Dak', 'Riet', rs.rietM2 || 0, 'm²', (rs.rietM2 || 0) * D.rietM2, (rs.rietM2 || 0) * D.rietMontage); }); // --- Woning-breed: dakrand, sandwichpanelen, verlichting --- const wo = newEl('WON', 'Woning (dakrand, fundering, verlichting)', null); const D = S.dak; if (totals.roofEdge) { const e = totals.roofEdge; add(wo, 'Dak', 'Dak', `Boeideel ${e.fasciaHeight} mm`, e.fasciaLengthM, 'm', e.fasciaLengthM * D.boeideelM, e.fasciaLengthM * D.boeideelMontage); add(wo, 'Dak', 'Dak', 'Onderplaat boeideel', e.soffitM2, 'm²', e.soffitM2 * D.onderplaatM2, e.soffitM2 * D.onderplaatMontage); add(wo, 'Dak', 'Dak', `Daktrim ${e.trimHeight} mm`, e.trimLengthM, 'm', e.trimLengthM * D.daktrimM, e.trimLengthM * D.daktrimMontage); } if (parts.sandwich) { const sw = parts.sandwich; add(wo, 'Vloer', 'Vloer', `Sandwichpanelen (${sw.panelCount} platen)`, sw.areaM2, 'm²', sw.areaM2 * S.vloer.sandwichM2, sw.panelCount * S.vloer.sandwichMontage); } const L = S.verlichting, lm = totals.lamps; add(wo, 'Verlichting', 'Verlichting', 'Gevellampen', lm.total, 'st.', lm.total * L.gevellamp, lm.total * L.montage); const spots = totals.roofEdge ? totals.roofEdge.spots : 0; add(wo, 'Verlichting', 'Verlichting', 'Boeideel-spots', spots, 'st.', spots * L.boeideelSpot, spots * L.montage); add(wo, 'Verlichting', 'Verlichting', 'Binnenspots', lm.indoor, 'st.', lm.indoor * L.binnenspot, lm.indoor * L.montage); // --- Optellen --- const sumLines = (lines) => lines.reduce((a, l) => ({ mat: a.mat + l.mat, sec: a.sec + l.sec, clock: a.clock + l.clock }), { mat: 0, sec: 0, clock: 0 }); elements.forEach(el => Object.assign(el, sumLines(el.lines))); const byKey = (key, order) => { const m = new Map(); elements.forEach(el => el.lines.forEach(l => { const r = m.get(l[key]) || { name: l[key], mat: 0, sec: 0 }; r.mat += l.mat; r.sec += l.sec; m.set(l[key], r); })); return Array.from(m.values()).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); }; // Kostprijs van materiaal + directe arbeidstijd (s), overal dezelfde // regels: materiaal + verlies + klein materiaal; uren + indirect + QA // tegen het uurloon (manuren); afschrijving/slijtage/huisvesting per // productie-uur = doorlooptijd (klokuren, niet x personeel) + indirect, zonder QA. const O = S.opslag, B = S.bedrijf, A = S.arbeid; const perHour = B.machines + B.slijtage + B.huisvesting; const split = (sec) => { const direct = sec / 3600, indirect = direct * A.toeslagUren / 100; const qa = (direct + indirect) * A.qa / 100; // QA: % van de totale werktijd return { direct, indirect, qa, total: direct + indirect + qa }; }; const prodHours = (clock) => clock / 3600 * (1 + A.toeslagUren / 100); const costOf = (r) => mat0(r.mat) + split(r.sec).total * A.uurloon + prodHours(r.clock) * perHour; const mat0 = (mat) => mat * (1 + (O.verlies + O.klein) / 100); elements.forEach(el => { el.kostprijs = costOf(el); }); const units = parts.units.map(u => { const r = Object.assign({ id: u.id }, sumLines(elements.filter(el => el.unit === u.id).flatMap(el => el.lines))); r.transport = B.transportUnit; r.kostprijs = costOf(r) + r.transport; return r; }); const rest = sumLines(elements.filter(el => el.unit === null).flatMap(el => el.lines)); units.push(Object.assign({ id: null, transport: 0 }, rest, { kostprijs: costOf(rest) + B.kraan })); const all = sumLines(elements.flatMap(el => el.lines)); const h = split(all.sec); h.prod = prodHours(all.clock); const n = parts.units.length; const pct = (v) => String(v).replace('.', ',') + '%'; const eur0 = (v) => eur(v); const posts = [ ['Materiaal', all.mat], [`Materiaalverlies ${pct(O.verlies)}`, all.mat * O.verlies / 100], [`Klein materiaal ${pct(O.klein)}`, all.mat * O.klein / 100], [`Arbeid ${uur(h.total)} manuur × ${eur0(A.uurloon)} (incl. bezetting, indirect en QA)`, h.total * A.uurloon], [`Afschrijving machines ${uur(h.prod)} productie-uur × ${eur(B.machines, 2)}`, h.prod * B.machines], [`Slijtage gereedschap ${uur(h.prod)} productie-uur × ${eur(B.slijtage, 2)}`, h.prod * B.slijtage], [`Huisvesting productiehal ${uur(h.prod)} productie-uur × ${eur(B.huisvesting, 2)}`, h.prod * B.huisvesting], [`Transport ${n} unit${n === 1 ? '' : 's'} × ${eur0(B.transportUnit)}`, n * B.transportUnit], ['Kraan plaatsen', B.kraan] ]; const kostprijs = posts.reduce((a, p) => a + p[1], 0); const algemeen = kostprijs * O.algemeen / 100; const marge = (kostprijs + algemeen) * O.marge / 100; const exBtw = kostprijs + algemeen + marge; const btw = exBtw * O.btw / 100; // Overzicht als rijen (kaart en PDF): sub = subtotaal. const rows = posts.filter(p => p[1] > 0).map(([label, amount]) => ({ label, amount })) .concat([ { label: 'Kostprijs', amount: kostprijs, sub: true }, { label: `Algemene kosten ${pct(O.algemeen)}`, amount: algemeen }, { label: `Marge ${pct(O.marge)}`, amount: marge }, { label: 'Totaal excl. btw', amount: exBtw, sub: true }, { label: `Btw ${pct(O.btw)}`, amount: btw } ]); return { settings: S, elements, units, saw: Object.assign(saw, { handSec: saw.robotSec * Z.handFactor, method: Z.methode }), categories: byKey('cat', CAT_ORDER), activities: byKey('act', ACT_ORDER), summary: { mat: all.mat, hoursDirect: h.direct, hoursIndirect: h.indirect, hoursQa: h.qa, hours: h.total, kostprijs, exBtw, btw, inclBtw: exBtw + btw, rows } }; } // Bedrag als "€ 1.234" (of met decimalen). function eur(n, decimals = 0) { return '€ ' + n.toLocaleString('nl-NL', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } // Uren als decimaal getal, bijv. "12,5". function uur(hours) { return hours.toLocaleString('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } return { STORAGE_KEY, defaultValues, getSettings, saveSettings, resetSettings, onChange, exportJson, importJson, compute, eur, uur }; })();