Files
crm/static/bestek.js
T

210 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 => `
<section class="bs-ch">
<div class="bs-ch-head"><h2>${esc(ch.name)}</h2><b>${eur(ch.price)}</b></div>
<table><tbody>${ch.lines.map(l => `<tr><td>${esc(l.label)}${l.product ? ` <span class="chip" title="Product ${esc(l.product.name)}">${esc(l.product.sku)}</span>` : ''}</td>
<td class="num">${nl(l.qty, l.qty % 1 ? (l.unit === 'm³' ? 2 : 1) : 0)} ${esc(l.unit)}</td><td class="num muted">× ${eur(l.unitPrice)}</td><td class="num">${eur(l.price)}</td></tr>`).join('')}
${ch.info.map(l => `<tr class="bs-info"><td>${esc(l.label)}</td><td class="num">${nl(l.qty, 1)} ${esc(l.unit)}</td><td></td><td></td></tr>`).join('')}</tbody></table>
</section>`).join('');
$('.bs-total').innerHTML = `<div><span>Totaal excl. btw</span><b>${eur(b.totals.exBtw)}</b></div>
<div><span>Btw ${nl(b.vat)}%</span><b>${eur(b.totals.btw)}</b></div>
<div class="grand"><span>Totaal incl. btw</span><b>${eur(b.totals.inclBtw)}</b></div>`;
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 = `<p class="empty">Dit ontwerp kan niet worden doorgerekend: ${esc(err.message)}</p>`;
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 = `<p class="empty">Het bestek kon niet worden berekend: ${esc(err.message)}</p>`;
}
};
// 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(); });
})();