/* Bestek uit een woningontwerp (house-design-JSON), voor een offerte. * * Rekent met de engine van de configurator (static/configurator/): HouseModel.computeParts zoals de PDF, * HouseModel.computeTotals en CostModel.compute met de kosten-CSV's. Elke kostenpost (materiaal + arbeid + * machine-uren, zoals CostModel.costOf) krijgt de algemene kosten en marge erbij en gaat naar een hoofdstuk; * transport en kraan worden een eigen hoofdstuk. Samen precies het totaal excl. btw van de configurator. * Productieposten (schroeven, zaagsneden, boorgaten, plaatsen) komen per hoofdstuk samen in één regel * "Bevestiging en bewerking"; zo heeft elke andere post een zuivere prijs per eenheid. * Posten met een vaste prijs per eenheid (wcd, toilet, rabat per m², ...) krijgen een product: SKU = de code uit * de kosten van de configurator (cost-defaults.js / leveranciers.csv), bijv. elektra.wcdDubbel. */ (() => { const root = document.getElementById('bestek'); const $ = (sel) => root.querySelector(sel); const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); const nl = (n, d = 0) => n.toLocaleString('nl-NL', { minimumFractionDigits: d, maximumFractionDigits: d }); const eur = (n) => '€ ' + nl(n, 2); // Hoofdstukken van het bestek; een post gaat naar het hoofdstuk van zijn activiteit (ACT) of anders zijn categorie. const CHAPTERS = [ ['casco', 'Casco en constructie'], ['isolatie', 'Isolatie'], ['gevel', 'Gevelbekleding'], ['dak', 'Dak, dakrand en overkapping'], ['vloer', 'Vloer en fundering'], ['kozijnen', 'Kozijnen, ramen en deuren'], ['afwerking', 'Binnenafwerking'], ['inrichting', 'Inrichting'], ['verlichting', 'Verlichting'], ['elektra', 'Elektra'], ['transport', 'Transport en plaatsing'] ]; const BY_CAT = { Hout: 'casco', Staal: 'casco', Schroeven: 'casco', Plaatwerk: 'casco', Isolatie: 'isolatie', Gevel: 'gevel', Dak: 'dak', Vloer: 'vloer', Kozijnen: 'kozijnen', Afwerking: 'afwerking', Inrichting: 'inrichting', Verlichting: 'verlichting', Elektra: 'elektra' }; const BY_ACT = { Latwerk: 'gevel', Dak: 'dak' }; const chapterOf = (l) => BY_ACT[l.act] || BY_CAT[l.cat] || 'casco'; // Productieposten: wel in de prijs, niet als regel in het bestek. const hidden = (l) => l.cat === 'Schroeven' || l.unit === 'sneden' || /^(Boorgaten|Plaatsen en vastzetten|Plaatschroeven)/.test(l.label); // Omschrijving zonder de details per element (aantallen tussen haakjes), zodat gelijke posten samengaan; // kozijnen houden hun maat en uitvoering. function clean(l) { let s = l.label; if (l.cat !== 'Kozijnen') s = s.replace(/\s*\([^)]*\)/g, ''); return s.replace(/^Regelwerk \d+ st\.$/, 'Regelwerk wanden').trim(); } const roundQty = (q, unit) => Math.round(q * (unit === 'm³' ? 100 : 10)) / (unit === 'm³' ? 100 : 10); const HIDDEN_LABEL = { casco: 'Bevestiging en bewerking (schroeven, zaagwerk, plaatsen)', elektra: 'Bevestiging en bewerking (boorwerk door het regelwerk)' }; const slug = (t) => t.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/²/g, '2').replace(/×/g, 'x').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); // Product bij een post: [test, code | (label, regel) => code, naam]. Alleen posten waarvan de prijs per eenheid vast // is (niet: regelwerk, vochtdoek, latwerk, groepenkast, verdeeldozen; die hangen af van het ontwerp). const PRODUCT_RULES = [ [/^Wandcontactdozen dubbel/, 'elektra.wcdDubbel', 'Wandcontactdoos dubbel, inbouw'], [/^Schakelaars/, 'elektra.schakelaar', 'Schakelaar, inbouw'], [/^Aansluitpunt koken/, 'elektra.perilex', 'Aansluitpunt kookgroep (perilex)'], [/^Aansluitpunt warmtepomp/, 'elektra.werkschakelaar', 'Aansluitpunt warmtepomp (werkschakelaar)'], [/^Aansluitpunt /, 'elektra.wcdEnkel', 'Aansluitpunt apparaat (wcd enkel, eigen groep)'], [/^Vloerdoos/, 'elektra.vloerdoosSt', 'Vloerdoos met dubbele wcd'], [/^Spotdozen/, 'elektra.spotdoosSt', 'Spotdoos plafond, luchtdicht'], [/^Kabeldoorvoer/, 'elektra.doorvoer', 'Kabeldoorvoer naar buiten, luchtdicht'], [/^Steekverbinder module-naad 3/, 'elektra.steekverbinder3', 'Steekverbinder module-naad 3-polig (set)'], [/^Steekverbinder module-naad 5/, 'elektra.steekverbinder5', 'Steekverbinder module-naad 5-polig (set)'], [(l) => l.cat === 'Elektra' && l.unit === 'm', (l) => `elektra.leiding.${slug(l.label.split(':')[0])}`, (l) => `Leiding ${l.label.split(':')[0]}, gelegd`], [/^Gevellampen/, 'verlichting.gevellamp', 'Gevellamp'], [/^Boeideel-spots/, 'verlichting.boeideelSpot', 'Boeideel-spot'], [/^Binnenspots/, 'verlichting.binnenspot', 'Binnenspot'], [/^Voordeur /, 'kozijnen.voordeur', 'Voordeur incl. kozijn'], [/^Deur /, 'kozijnen.deur', 'Deur incl. kozijn'], [/^Schuifdeur /, 'kozijnen.schuifdeurBlad', 'Schuifdeur per blad'], [/^Raam \d+×\d+/, (l) => `kozijnen.raam.${l.label.match(/\d+×\d+/)[0].replace('×', 'x')}`, (l) => `Raamkozijn ${l.label.match(/\d+×\d+/)[0]} mm incl. glas`], [/^Rabat$/, 'gevel.rabatM2', 'Rabat gevelbekleding'], [/^Steenstrips$/, 'gevel.steenM2', 'Steenstrips'], [/^Stucwerk$/, 'gevel.stucM2', 'Stucwerk gevel'], [(l) => l.cat === 'Gevel' && /^Felsplaten/.test(l.label), 'gevel.felsM2', 'Felsplaten gevel (staande naad)'], [/^Afdekker trasraam/, 'gevel.afdekkerM', 'Afdekker trasraam'], [/^Isolatieblok steen/, 'isolatie.steenIsolatieM3', 'Isolatieblok steengevel'], [/^Glaswol/, 'isolatie.glaswolM3', 'Glaswol isolatie'], [/^Dakleer/, 'dak.dakleerM2', 'Dakleer (EPDM)'], [/^Riet$/, 'dak.rietM2', 'Rieten dakbedekking'], [(l) => l.cat === 'Dak' && /^Felsplaten/.test(l.label), 'dak.felsM2', 'Felsplaten dak (staande naad)'], [/^Boeideel \d+/, 'dak.boeideelM', 'Boeideel'], [/^Onderplaat boeideel/, 'dak.onderplaatM2', 'Onderplaat boeideel'], [/^Daktrim/, 'dak.daktrimM', 'Daktrim'], [/^Ventilatierooster/, 'dak.ventRoosterM', 'Ventilatierooster onder boeideel'], [/^Paalvoeten/, 'dak.paalvoet', 'Paalvoet verzinkt, verstelbaar'], [/^Dakbeschot/, 'dak.beschotM2', 'Dakbeschot OSB 18 mm'], [/^Spaanplaat$/, 'plaat.spaanplaatM2', 'Spaanplaat'], [/^Cementplaat$/, 'plaat.cementplaatM2', 'Cementgebonden plaat'], [/^OSB /, 'plaat.osbM2', 'OSB-plaat'], [/^Vloerafwerking/, 'vloer.afwerkingM2', 'Vloerafwerking'], [/vals plafond/, 'afwerking.valsPlafondM2', 'Vals plafond (gipsplaat, afgewerkt)'], [/FIBO-wandpanelen/, 'afwerking.fiboM2', 'FIBO-wandpaneel'] ]; // Inrichting: de code volgt het stuk zelf (FixtureModel: f.cost), de omschrijving is die van CostModel. function fixtureCodes(parts) { const map = {}; (parts.fixtures || []).filter(f => f.cost).forEach(f => { const perM = { keuken: ['Keukenblok', 'keukenM'], kookeiland: ['Kookeiland', 'kookeilandM'], kast: [f.label, 'kastM'] }[f.cost]; map[perM ? `${perM[0]} ${f.w} mm` : f.label] = perM ? perM[1] : f.cost; if (f.opts && f.opts.kookplaat) map.Inductiekookplaat = 'kookplaat'; }); return map; } function productOf(row, fixtures) { let code = null, name = null; if (row.cat === 'Inrichting' && fixtures[row.label]) { code = `inrichting.${fixtures[row.label]}`; name = row.label.replace(/ \d+ mm$/, ''); } else { const rule = PRODUCT_RULES.find(([t]) => typeof t === 'function' ? t(row) : t.test(row.label)); if (!rule) return null; code = typeof rule[1] === 'function' ? rule[1](row) : rule[1]; name = typeof rule[2] === 'function' ? rule[2](row) : rule[2]; } const [g, k] = code.split('.'), def = (window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k]) || null; const sup = CostCsv.entry('leveranciers', code) || {}; return { sku: code, name, description: [def && def.label, 'Prijs uit de configurator: materiaal en montage, incl. opslagen en marge.'].filter(Boolean).join('\n'), supplier: sup.supplier || '', article: sup.article || '' }; } function build(st, parts, meta) { const totals = HouseModel.computeTotals(parts); const cost = CostModel.compute(parts, totals); const S = cost.settings, O = S.opslag, A = S.arbeid, B = S.bedrijf; const perHour = B.machines + B.slijtage + B.huisvesting; const factor = (1 + O.algemeen / 100) * (1 + O.marge / 100); // Zelfde opbouw als costOf in CostModel.compute. const lineCost = (l) => { const direct = l.sec / 3600, indirect = direct * A.toeslagUren / 100, qa = (direct + indirect) * A.qa / 100; return l.mat * (1 + (O.verlies + O.klein) / 100) + (direct + indirect + qa) * A.uurloon + l.clock / 3600 * (1 + A.toeslagUren / 100) * perHour; }; const chapters = new Map(CHAPTERS.map(([key, name]) => [key, { key, name, price: 0, rows: new Map(), info: [] }])); cost.elements.forEach(el => el.lines.forEach(l => { const ch = chapters.get(chapterOf(l)), price = lineCost(l) * factor; ch.price += price; const label = hidden(l) ? (HIDDEN_LABEL[ch.key] || 'Bevestiging en bewerking') : clean(l); const unit = hidden(l) ? 'post' : l.unit, key = `${hidden(l) ? '~' : ''}${label}|${unit}`; const row = ch.rows.get(key) || { label, unit, qty: 0, n: 0, cost: 0, cat: l.cat, hidden: hidden(l) }; row.qty = row.hidden ? 1 : row.qty + l.qty; row.n += 1; row.cost += price; ch.rows.set(key, row); })); const fixtures = fixtureCodes(parts); const n = parts.units.length, tr = chapters.get('transport'); tr.rows.set('t', { label: `Transport modules naar de bouwplaats`, unit: 'unit', qty: n, n: 1, cost: n * B.transportUnit * factor }); tr.rows.set('k', { label: 'Plaatsen met kraan', unit: 'post', qty: 1, n: 1, cost: B.kraan * factor }); tr.price = (n * B.transportUnit + B.kraan) * factor; const out = []; chapters.forEach(ch => { if (!(ch.price > 0.005)) return; // Bevestiging en bewerking als laatste regel van het hoofdstuk (daar komt ook het afrondingsverschil). const rows = Array.from(ch.rows.values()).filter(r => r.qty > 0 && (r.cost > 0.005 || !r.hidden)).sort((a, b) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0)); const lines = rows.map(r => { // Ramen tellen per stuk (de prijs rekent per m²); een raam van een andere maat is een andere post. const perPiece = r.cat === 'Kozijnen' && r.unit === 'm²'; const q0 = roundQty(r.qty, r.unit), qty = perPiece ? r.n : q0 > 0 ? q0 : Math.round(r.qty * 1000) / 1000 || 1; const line = { label: r.label, qty, unit: perPiece ? 'st.' : r.unit, unitPrice: Math.round(r.cost / qty * 10000) / 10000, price: Math.round(r.cost * 100) / 100 }; const product = r.hidden ? null : productOf(r, fixtures); if (product) line.product = product; return line; }); if (ch.key === 'elektra') { const m = rows.filter(r => r.unit === 'm').reduce((t, r) => t + r.qty, 0); if (m > 0) ch.info.push({ label: 'Totaal leidingwerk (buis en kabel)', qty: roundQty(m, 'm'), unit: 'm' }); } out.push({ key: ch.key, name: ch.name, price: Math.round(ch.price * 100) / 100, lines, info: ch.info }); }); const sum = out.reduce((t, c) => t + c.price, 0); if (Math.abs(sum - cost.summary.exBtw) > 1) console.warn('Bestek wijkt af van de configurator', sum, cost.summary.exBtw); return Object.assign({ version: 1, computedAt: new Date().toISOString(), vat: O.btw, chapters: out, totals: { exBtw: Math.round(cost.summary.exBtw * 100) / 100, btw: Math.round(cost.summary.btw * 100) / 100, inclBtw: Math.round(cost.summary.inclBtw * 100) / 100 }, units: n }, meta); } function render(b) { $('.bs-body').innerHTML = b.chapters.map(ch => `

${esc(ch.name)}

${eur(ch.price)}
${ch.lines.map(l => ``).join('')} ${ch.info.map(l => ``).join('')}
${esc(l.label)}${l.product ? ` ${esc(l.product.sku)}` : ''} ${nl(l.qty, l.qty % 1 ? (l.unit === 'm³' ? 2 : 1) : 0)} ${esc(l.unit)}× ${eur(l.unitPrice)}${eur(l.price)}
${esc(l.label)}${nl(l.qty, 1)} ${esc(l.unit)}
`).join(''); $('.bs-total').innerHTML = `
Totaal excl. btw${eur(b.totals.exBtw)}
Btw ${nl(b.vat)}%${eur(b.totals.btw)}
Totaal incl. btw${eur(b.totals.inclBtw)}
`; root.querySelector('[name=bestek]').value = JSON.stringify(b); root.querySelector('button[type=submit]').disabled = false; } let st, parts; try { const design = JSON.parse(document.getElementById('design').textContent); st = HouseModel.normalize(HouseModel.clone(design)); parts = HouseModel.computeParts(st, { wallMaterials: WallModel.createMaterials(), floorMaterials: FloorModel.createMaterials(), roofMaterials: RoofModel.createMaterials(), elektraRough: false }); } catch (err) { console.error(err); $('.bs-body').innerHTML = `

Dit ontwerp kan niet worden doorgerekend: ${esc(err.message)}

`; return; } const meta = JSON.parse(document.getElementById('bestek-meta').textContent); const run = () => { try { render(build(st, parts, meta)); } catch (err) { console.error(err); $('.bs-body').innerHTML = `

Het bestek kon niet worden berekend: ${esc(err.message)}

`; } }; // Prijzen komen uit kosten/*.csv (async geladen bij het starten van de engine); daarna rekenen. let done = false; const once = () => { if (!done) { done = true; run(); } }; if (CostCsv.status('intern').loadedAt) once(); else { CostCsv.onChange(once); setTimeout(once, 6000); } CostModel.onChange(() => { if (done) run(); }); })();