v0.0.2: pre-productie (Bootstrap 5.3.3, LightPdf klant-PDF, server-setup en release-script)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bffa053c05
commit
7d302dc405
@@ -0,0 +1,209 @@
|
||||
/* 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 => `
|
||||
<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(); });
|
||||
})();
|
||||
Reference in New Issue
Block a user