416 lines
26 KiB
JavaScript
416 lines
26 KiB
JavaScript
/*!
|
||
* 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', 'Elektra'];
|
||
const ACT_ORDER = ['Zagen', 'Regelwerk monteren', 'Wanden plaatsen', 'Plaatwerk', 'Isoleren', 'Gevel', 'Latwerk', 'Dak', 'Vloer', 'Kozijnen', 'Verlichting', 'Elektra'];
|
||
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);
|
||
addSheet(rf, 'Plaatwerk', `Dakbeschot OSB ${RoofModel.BESCHOT_MM} mm`, rs.beschotM2 || 0, D.beschotM2, D.beschotMontage);
|
||
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);
|
||
add(rf, 'Dak', 'Dak', 'Felsplaten (staande naad)', rs.felsM2 || 0, 'm²', (rs.felsM2 || 0) * D.felsM2, (rs.felsM2 || 0) * D.felsMontage);
|
||
});
|
||
|
||
// --- 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);
|
||
|
||
// --- Elektra (ElektraModel): per unit de leidingen (strekkende meters
|
||
// per type: buis + draad per ader, of XMvK), boorgaten, steekverbinders,
|
||
// dozen en aansluitingen; meterkast, groepen en keuring bij de unit waar
|
||
// de meterkast hangt. Arbeid met E.personeel man.
|
||
const elk = parts.elektra, E = S.elektra;
|
||
if (elk && elk.enabled) {
|
||
const unitIds = new Set(parts.units.map(u => u.id)), elOf = {};
|
||
const elFor = (uid) => {
|
||
const id = unitIds.has(uid) ? uid : null, k = id || '-';
|
||
return elOf[k] || (elOf[k] = newEl(id ? `${id}-EL` : 'EL', id ? `Unit ${id} elektra` : 'Elektra', id));
|
||
};
|
||
const ea = (uid, label, qty, unit, mat, sec) => add(elFor(uid), 'Elektra', 'Elektra', label, qty, unit, mat, sec, E.personeel);
|
||
const mkUnit = elk.meterkast ? elk.meterkast.unitId : null;
|
||
const groupUnit = (nr) => (elk.groups.find(g => g.nr === nr) || {}).unitId || mkUnit;
|
||
const buisPrijs = (d) => d >= 25 ? E.buis25M : d >= 19 ? E.buis19M : E.buis16M;
|
||
const agg = {};
|
||
elk.runs.forEach(r => {
|
||
const uid = groupUnit(r.group), a = agg[uid] = agg[uid] || { types: {}, holes: 0, cross: {} };
|
||
a.types[r.type] = (a.types[r.type] || 0) + r.lengthMM / 1000;
|
||
a.holes += r.holes || 0;
|
||
if (r.crossings) { const n = ElektraModel.cableInfo(r.type).cores.length; a.cross[n] = (a.cross[n] || 0) + r.crossings; }
|
||
});
|
||
Object.keys(agg).forEach(uid => {
|
||
const a = agg[uid];
|
||
Object.keys(a.types).sort().forEach(type => {
|
||
const c = ElektraModel.cableInfo(type), m = a.types[type];
|
||
if (c.conduit) {
|
||
const draad = c.mm2 >= 2.5 ? E.draad25M : E.draad15M;
|
||
ea(uid, `${c.label}: ${c.use.toLowerCase()} (buis + ${c.cores.length} aders)`, m, 'm', m * (buisPrijs(c.conduit) + c.cores.length * draad), m * (E.buisLeggen + E.draadTrekken));
|
||
} else ea(uid, `${c.label}: ${c.use.toLowerCase()}`, m, 'm', m * E.xmvkM, m * E.kabelLeggen);
|
||
});
|
||
ea(uid, `Boorgaten stijlen/regels/balken (${E.boorgat} s per gat)`, a.holes, 'st.', 0, a.holes * E.boorgat);
|
||
Object.keys(a.cross).forEach(n => {
|
||
const k = a.cross[n];
|
||
ea(uid, `Steekverbinder module-naad ${n}-polig`, k, 'set', k * (Number(n) >= 5 ? E.steekverbinder5 : E.steekverbinder3), k * E.steekverbinderMontage);
|
||
});
|
||
});
|
||
const count = (list, uid) => list.filter(x => x.unitId === uid).length;
|
||
parts.units.forEach(u => {
|
||
const uid = u.id;
|
||
const nSw = count(elk.switches, uid), combos = elk.combos.filter(c => c.unitId === uid);
|
||
const raam1 = combos.filter(c => c.switches.length === 1).length, raam2 = combos.length - raam1;
|
||
ea(uid, `Schakelaars (${raam1}× 1-voudig, ${raam2}× 2-voudig raam)`, nSw, 'st.',
|
||
nSw * (E.schakelaar + E.inbouwdoos) + raam1 * E.afdekraam1 + raam2 * E.afdekraam2, nSw * (E.doosPlaatsen + E.afmonteren));
|
||
const nW = count(elk.wcds, uid);
|
||
ea(uid, 'Wandcontactdozen dubbel (2 inbouwdozen)', nW, 'st.', nW * (E.wcdDubbel + 2 * E.inbouwdoos), nW * (2 * E.doosPlaatsen + E.afmonteren));
|
||
elk.apps.filter(a => a.unitId === uid).forEach(a => {
|
||
const price = a.app === 'koken' ? E.perilex : a.app === 'warmtepomp' ? E.werkschakelaar : E.wcdEnkel;
|
||
ea(uid, `Aansluitpunt ${a.label.toLowerCase()}`, 1, 'st.', price + E.inbouwdoos, E.doosPlaatsen + E.afmonteren);
|
||
});
|
||
const nV = count(elk.floorWcds, uid);
|
||
ea(uid, 'Vloerdoos met dubbele wcd', nV, 'st.', nV * E.vloerdoosSt, nV * (E.vloerdoos + E.afmonteren));
|
||
const nB = count(elk.boxes, uid), nKl = nB * 3 + nSw + nW;
|
||
ea(uid, `Verdeeldozen plafond (+ ${nKl} lasklemmen)`, nB, 'st.', nB * E.verdeeldoosSt + nKl * E.lasklem, nB * E.verdeeldoos);
|
||
const nSpot = elk.switches.filter(sw => sw.unitId === uid).reduce((t, sw) => t + sw.lamps.filter(l => l.kind === 'binnenspot').length, 0);
|
||
ea(uid, 'Spotdozen plafond (luchtdicht)', nSpot, 'st.', nSpot * E.spotdoosSt, nSpot * E.spotdoos);
|
||
const nDv = elk.runs.filter(r => r.type === 'buiten' && r.from.startsWith('VD') && groupUnit(r.group) === uid).length;
|
||
ea(uid, 'Kabeldoorvoer naar buiten', nDv, 'st.', nDv * E.doorvoer, 0);
|
||
});
|
||
if (elk.meterkast) {
|
||
const g1 = elk.groups.filter(g => (g.poles || 1) === 1).length, g2 = elk.groups.length - g1;
|
||
ea(mkUnit, `Groepenkast 3×25 A: kast, hoofdschakelaar, ${elk.rcdCount} aardlek`, 1, 'st.', E.kast + E.hoofdschakelaar + elk.rcdCount * E.aardlek, E.kastMontage);
|
||
ea(mkUnit, `Groepen: ${g1}× automaat 1P+N${g2 ? `, ${g2}× 2-fase` : ''}`, elk.groups.length, 'groep', g1 * E.automaat + g2 * E.automaat2f, elk.groups.length * E.groepMontage);
|
||
ea(mkUnit, 'Meten, testen en opleveren', 1, 'woning', 0, E.keuring);
|
||
}
|
||
}
|
||
|
||
// --- 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 };
|
||
})();
|