Files
Tectum/js/cost/cost-csv.js
T

200 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/*!
* Tectum
* Copyright © 2026 Machiel Heinen.
* Alle rechten voorbehouden. Gebruik uitsluitend met schriftelijke toestemming.
* Zie LICENTIE.md.
*/
window.CostCsv = (() => {
const BALK_RE = /^hout\.balk\.(\d+)x(\d+)$/;
const LISTS = {
intern: { url: 'kosten/intern.csv', label: 'Intern', pricesOnly: false },
leveranciers: { url: 'kosten/leveranciers.csv', label: 'Leveranciers', pricesOnly: true }
};
const listeners = [];
const empty = () => ({ source: null, name: null, loadedAt: null, values: {}, unknown: [], errors: [] });
const state = { intern: empty(), leveranciers: empty() };
function splitLine(line, sep) {
const out = [];
let cur = '', q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) {
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (ch === '"') q = false;
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === sep) { out.push(cur.trim()); cur = ''; }
else cur += ch;
}
out.push(cur.trim());
return out;
}
function detectSep(header) {
const n = (c) => header.split(c).length;
return [';', '\t', ','].reduce((best, c) => n(c) > n(best) ? c : best, ';');
}
function parseNumber(s) {
let t = String(s || '').replace(/[^\d,.\-]/g, '');
if (!t) return NaN;
const c = t.lastIndexOf(','), d = t.lastIndexOf('.');
if (c >= 0 && d >= 0) t = c > d ? t.replace(/\./g, '').replace(',', '.') : t.replace(/,/g, '');
else if (c >= 0) t = t.replace(',', '.');
return parseFloat(t);
}
const normUnit = (u) => String(u || '').toLowerCase().replace(/\s+/g, '').replace(/^eur(o)?/, '€').replace(/\.$/, '').replace('m2', 'm²').replace('m3', 'm³');
const COLS = {
code: ['code', 'sleutel', 'id'],
value: ['waarde', 'prijs', 'value', 'price'],
unit: ['eenheid', 'unit'],
label: ['omschrijving', 'naam', 'description'],
supplier: ['leverancier', 'supplier'],
article: ['artikelnummer', 'artikel', 'sku'],
updated: ['bijgewerkt', 'datum', 'date', 'updated']
};
const isPrice = (it) => !it.options && /^€/.test(it.unit || '') && it.unit !== '€/u';
function itemFor(code) {
if (BALK_RE.test(code)) return { unit: '€/m', price: true };
const [g, k] = code.split('.');
const it = window.COST_DEFAULTS[g] && window.COST_DEFAULTS[g].items[k];
return it ? Object.assign({ price: isPrice(it) }, it) : null;
}
function optionKey(it, s) {
const t = String(s || '').trim().toLowerCase();
return Object.keys(it.options).find(k => k.toLowerCase() === t || String(it.options[k]).toLowerCase() === t);
}
function parse(text, list) {
const pricesOnly = LISTS[list].pricesOnly;
const lines = String(text).replace(/^/, '').split(/\r?\n/);
const hi = lines.findIndex(l => l.trim() && !l.trim().startsWith('#'));
if (hi < 0) return { values: {}, unknown: [], errors: [] };
const sep = detectSep(lines[hi]);
const head = splitLine(lines[hi], sep).map(h => h.toLowerCase());
const col = {};
Object.entries(COLS).forEach(([k, names]) => { col[k] = head.findIndex(h => names.includes(h)); });
if (col.code < 0 || col.value < 0) throw new Error('kolommen "code" en "waarde" (of "prijs") ontbreken');
const values = {}, unknown = [], errors = [];
for (let i = hi + 1; i < lines.length; i++) {
if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue;
const f = splitLine(lines[i], sep), get = (k) => col[k] >= 0 ? (f[col[k]] || '') : '';
const code = get('code'), raw = get('value'), line = i + 1;
if (!code) continue;
const it = itemFor(code);
if (!it) { unknown.push({ code, line, label: get('label') }); continue; }
if (it.price !== pricesOnly) { errors.push(`regel ${line}: ${code} hoort in ${it.price ? 'leveranciers.csv' : 'intern.csv'}`); continue; }
let value;
if (it.options) {
value = optionKey(it, raw);
if (value === undefined) { errors.push(`regel ${line}: ${code}: "${raw}" is geen keuze (${Object.keys(it.options).join(', ')})`); continue; }
} else {
value = parseNumber(raw);
if (!(isFinite(value) && value >= 0)) { errors.push(`regel ${line}: ${code}: geen geldig getal "${raw}"`); continue; }
if (get('unit') && normUnit(get('unit')) !== normUnit(it.unit)) { errors.push(`regel ${line}: ${code}: eenheid "${get('unit')}", verwacht ${it.unit}`); continue; }
}
const prev = values[code];
if (prev && (!pricesOnly || prev.value <= value)) {
if (!pricesOnly) errors.push(`regel ${line}: ${code} staat ook op regel ${prev.line}; die telt`);
continue;
}
values[code] = { value, unit: it.unit || '', label: get('label'), supplier: get('supplier'), article: get('article'), updated: get('updated'), line };
}
return { values, unknown, errors };
}
const notify = () => listeners.forEach(fn => fn());
function apply(list, text, source, name, loadedAt) {
try {
state[list] = Object.assign({ source, name, loadedAt }, parse(text, list));
} catch (e) {
state[list] = Object.assign(empty(), { source, name, loadedAt, errors: [e.message] });
}
}
function loadList(list) {
const url = LISTS[list].url;
return fetch(url, { cache: 'no-store' })
.then(r => r.ok ? r.text().then(t => apply(list, t, 'server', url, r.headers.get('last-modified') || new Date().toISOString())) : null)
.catch(() => null);
}
function load() {
return Promise.all(Object.keys(LISTS).map(loadList)).then(notify);
}
function loadText(list, text, name) {
parse(text, list);
apply(list, text, 'tijdelijk', name, new Date().toISOString());
notify();
}
function reload(list) {
state[list] = empty();
return loadList(list).then(notify);
}
const entry = (list, code) => state[list].values[code] || null;
const labelOf = (code, fallback) => (entry('intern', code) || entry('leveranciers', code) || {}).label || fallback;
const balkKey = (w, h) => `${Math.round(Math.min(w, h))}x${Math.round(Math.max(w, h))}`;
function overlay(list, values) {
Object.entries(state[list].values).forEach(([code, e]) => {
const [g, k] = code.split('.');
if (values[g] && k in values[g]) values[g][k] = e.value;
});
return values;
}
function balken(list) {
const out = {};
Object.entries(state[list].values).forEach(([code, e]) => {
const m = code.match(BALK_RE);
if (m) out[balkKey(+m[1], +m[2])] = e.value;
});
return out;
}
function status(list) {
const s = state[list];
return { url: LISTS[list].url, label: LISTS[list].label, source: s.source, name: s.name, loadedAt: s.loadedAt, count: Object.keys(s.values).length, unknown: s.unknown.slice(), errors: s.errors.slice() };
}
function onChange(fn) { listeners.push(fn); return () => { const i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1); }; }
function template(list, values, balkMaten) {
const pricesOnly = LISTS[list].pricesOnly;
const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v;
const rows = [pricesOnly ? ['code', 'omschrijving', 'eenheid', 'prijs', 'leverancier', 'artikelnummer', 'bijgewerkt'] : ['code', 'omschrijving', 'eenheid', 'waarde']];
const row = (code, label, unit, v) => {
const e = entry(list, code) || {};
rows.push(pricesOnly ? [code, label, unit, num(v), e.supplier || '', e.article || '', e.updated || ''] : [code, label, unit, num(v)]);
};
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => {
if (pricesOnly === isPrice(it)) row(`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', values[g][k]);
}));
if (!pricesOnly) return finish(rows);
const have = balken(list), maten = (balkMaten || []).concat(Object.keys(have).map(k => k.split('x').map(Number)));
const seen = new Set();
maten.forEach(([w, h]) => {
const key = balkKey(w, h);
if (seen.has(key)) return;
seen.add(key);
const perM = have[key] !== undefined ? have[key] : w * h / 1e6 * values.hout.constructieM3;
row(`hout.balk.${key}`, labelOf(`hout.balk.${key}`, `Constructiehout vuren C24 ${key.replace('x', '×')}`), '€/m', perM);
});
return finish(rows);
}
function changes(values, base) {
const num = (v) => typeof v === 'number' ? String(Math.round(v * 10000) / 10000).replace('.', ',') : v;
const rows = [['code', 'omschrijving', 'eenheid', 'waarde', 'standaard']];
Object.entries(window.COST_DEFAULTS).forEach(([g, grp]) => Object.entries(grp.items).forEach(([k, it]) => {
if (isPrice(it) || values[g][k] === base[g][k]) return;
rows.push([`${g}.${k}`, labelOf(`${g}.${k}`, it.label), it.unit || '', num(values[g][k]), num(base[g][k])]);
}));
return rows.length > 1 ? finish(rows) : null;
}
// BOM: Excel/LibreOffice herkennen dan UTF-8 (€, ², ×).
function finish(rows) {
const q = (s) => /[;"\n]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : s;
return '\uFEFF' + rows.map(r => r.map(q).join(';')).join('\n') + '\n';
}
return { LISTS, isPrice, load, loadText, reload, entry, labelOf, overlay, balken, balkKey, status, onChange, template, changes, parse };
})();
CostCsv.load();