Vloer-configurator — Berekeningsregels

Inhoud
  1. 1. Regel-prijs (basis-formule)
  2. 2. Default-aantal per record (suggestAantal)
  3. 3. Afgeleid aantal (bronVoorAantal + pakLengte + snijfactor)
  4. 4. snijFactor() — bron-volgorde voor snijverlies
  5. 5. Multi-keuze limiet (profielen)
  6. 6. Stuks-display voor plinten/profielen
  7. 7. Producten-tabel (snijverlies + pak-rounding)
  8. 8. Order-overzicht totalen
  9. 9. Uitgewerkte voorbeelden
  10. 10. Zoho-veld-mapping

1. Regel-prijs (basis-formule)

Voor elke regel in de selectie (uitgevoerd door regelPrijs(sel)):

// Vast-berekening (single-stuk producten zoals verdeler-CV, voorinspectie) regel_prijs = prijsPerUnit // Variabel-berekening (m¹/m²/stuks) regel_prijs = max(aantal, aantalMin) × prijsPerUnit × snijFactor(art) // Restpartij — vaste partij-prijs ongeacht aantal regel_prijs = prijsPerUnit (1×)
VeldBronDefault
aantalsel.aantal (door klant of suggestAantal ingevuld)
aantalMinart.aantalMin uit TSV/Zoho0 of 1
prijsPerUnitart.prijsPerUnit uit Zoho Productsverplicht
snijFactor(art)Helper met bron-volgorde: omrekenfactor → snijverlies-veld → default (zie §4)1.10 voor Vloeren, 1.0 voor services
berekeningart.berekening: Variabel of VastVariabel
aantalMin-minimum: als klant minder invult dan aantalMin, wordt voor de prijs-berekening alsnog aantalMin gebruikt. Voorbeeld: leg-egaliseren heeft aantalMin=35 m² — een ruimte van 20 m² wordt op 35 m² gefactureerd.

2. Default-aantal per record (suggestAantal)

Wanneer een record nieuw aan de selectie wordt toegevoegd (Verplicht-auto-add of klant klikt Toevoegen) berekent suggestAantal(art, r) het default-aantal in deze volgorde (early-return op de eerste match):

#ConditieResultaat
1productCode === 'leg-houtvisgraat'r.m2 — hout-visgraat-leg-arbeid; Zoho-eenheid is m¹ maar berekening volgt ruimte-m²
2Jumpax-pair-codes (861202, Xcx170123)ceil(r.m2 / rolM2) × rolM2 — pair-products verkocht per volle rol (861202 = 50 m²/rol, Xcx170123 = 15 m²/rol)
3aproductCode === 'leg-dichtzet-vlrvrwa' + r.aanleggenVerwarming === 'LAB21'r.vloerverwarmingM2 (fallback r.m2) — dichtzet volgt VV-oppervlak (alleen freesgangen worden dichtgezet)
3bproductCode === 'leg-vloerverwarm-ega' + r.aanleggenVerwarming === 'LAB21'r.m2 — egaliseren wordt altijd over de volledige basisvloer gedaan, ook waar geen VV ligt (je kunt geen halve egalisatielaag aanbrengen)
3cproductCode matcht /^Leg-Vloerverw-gr\d+$/i1 — Vast bedrag per gr-tier; m2Range selecteert welk record fired
3dproductCode === 'VV-Afvoerspullen'N (aantal VV-groepen uit Leg-Vloerverw-grN), fallback 1 — 1× afvoerspullen per groep
3eproductCode === 'VV-brandstof'1 — Vast bedrag; dedupe naar 1× per order in syncVVBrandstof
4art.aantalMin === 00 — klant past zelf aan (Leg-massieve-plint, leg-platteplint Verplicht-records)
5maten ∈ {Stuk, St, Koker, Bus} (case-insensitive)art.aantalMin || 1 (Leg-kast, Leg-toilet, Leg-techruimte, KC25 bij directe selectie)
6art.bronVoorAantal gezetAfgeleid uit bron — zie §3
7maten === 'm1'r.m1Rand (default 0 als niet ingevuld in ruimte-info)
8fallbackr.m2 (ruimte-oppervlakte)

Dynamische rebind (syncVerplichteSelecties §1b)

Voor cases 1, 2, 3a-e herrekent de §1b-loop het aantal bij elke render — niet alleen bij toevoeg-moment. Dit zorgt dat:

Prompt bij €0-regel

Bij pickArtikel voor een Optioneel/Keuze record met aantal=0 én maten m¹/m²/m²: verschijnt een prompt "Vul aantal m¹ in voor [naam]". Klant kan annuleren (geen toevoeging) of bedrag invullen. Voorkomt €0-regels in de selectie.

3. Afgeleid aantal (bronVoorAantal + pakLengte + snijfactor)

Records met een bronVoorAantal-veld krijgen hun aantal afgeleid van een ander record in de selectie. Drie velden bepalen de formule:

VeldBetekenis
bronVoorAantalproductCode van het parent-record waar het aantal vandaan komt
pakLengtelengte per stuk/pak: 2.4m voor plinten, 1m voor profielen, 20m voor kit-bus, 75.55m voor PVC voegstrip-koker
snijfactorsnijverlies-factor: 1.10 (=10% extra) voor plinten/deklijsten, 1.0 voor kit/profielen

Formule per maten-type

raw = bron.aantal × snijfactor // m¹ / m² eenheden (plinten, deklijsten in m¹) aantal = ceil(raw / pakLengte) × pakLengte // afgerond op veelvoud van pakLengte // Discrete stuks (Stuk / Koker / Bus) aantal = max(1, ceil(raw / pakLengte)) // minimaal 1 stuk wanneer bron > 0

Praktische toepassingen

ParentVervolgpakLengtesnijfactor
leg-platteplint (m¹)162 platte-plint productcodes (m¹)2.41.10
Leg-massieve-plint (m¹)45 deklijsten (m¹)2.41.10
leg-hogeplint (m¹)28 hoge plinten (m¹)2.41.10
Leg-profiel (Stuk)62 profielen (St)11.0
Leg-afkithogeplint (m¹)KC25 290261 (Stuk)201.0
Leg-afkitvloer (m¹)KC25 100325 (Stuk)201.0
Leg-pvcbies (m¹)419210 voegstrip (Koker)75.551.0
Leg-pvcbies (m¹)419206 voegstrip (Koker)72.801.0

Voorbeeld 1 — plint bij leg-hogeplint = 25 m¹

aantal_plint = ceil(25 × 1.10 / 2.4) × 2.4 = ceil(11.458) × 2.4 = 12 × 2.4 = 28.8 m¹
Display: 12 stuks à 2,4m (28,8 m¹)
Prijs (à €7,95/m¹): 28.8 × 7.95 = €228,96

Voorbeeld 2 — KC25 290261 bus bij Leg-afkithogeplint = 50 m¹

aantal_bus = max(1, ceil(50 × 1.0 / 20)) = max(1, 3) = 3 bussen
Prijs (à €14,95/stuk): 3 × 14.95 = €44,85

Voorbeeld 3 — PVC voegstrip 419210 bij Leg-pvcbies = 30 m¹

aantal_koker = max(1, ceil(30 × 1.0 / 75.55)) = max(1, 1) = 1 koker
Prijs (à €150/koker): €150
Bij 100 m¹: ceil(100/75.55) = 2 kokers = €300.

4. snijFactor() — bron-volgorde voor snijverlies

De multiplier voor snijverlies / pak-conversie wordt centraal bepaald in snijFactor(art). Bron-volgorde:

function snijFactor(art): // 1. Directe Zoho-vermenigvuldiger (incl. pak-lengte voor Accessoires-plinten, // waar 1 m¹ = 2.4 stuk-prijs). Wint altijd als gezet. if art.omrekenfactor > 0: return art.omrekenfactor // 2. Productspecifiek snijverlies-percentage (Zoho-veld Snijverlies, in %) if art.snijverlies geldig: return 1 + (art.snijverlies / 100) // 3. Default: 10% snij voor Vloeren-records zonder data, 1.0 voor services return art.hoofdcategorie === 'Vloeren' ? 1.10 : 1.0

Gebruikt door regelPrijs(sel) én door de Producten-tabel-loop (§7). Voor Xcx-MV88001-7-03 (Sensation PVC met snijverlies=5) levert dit factor 1.05 — niet langer het verkeerde 1.10-default uit oude code.

Record-soortVeldenResultaat
Vloeren met snijverlies-veldsnijverlies=5, omrekenfactor=null1.05 (5% snij)
Vloeren zonder snij-databeide null1.10 (10% default voor Vloeren)
Accessoires-plintenomrekenfactor=2.4, snijverlies=102.4 (pak-conversie wint)
Services (Diensten)beide null1.0 (geen impliciete snij)

Override: 18% snij bij PVC-Lijm-visgraat met Leg-pvcband

In de Producten-tabel-loop (§7) wordt de snij-factor overgeschreven naar 1.18 wanneer:

Reden: bies-werk in combinatie met een grafisch punt-patroon geeft fors meer afval dan het standaard 10%. De 18%-bump zit alleen in renderOrderSummary's Vloeren-loop — snijFactor() zelf is contextloos en blijft de bron-volgorde-helper.

5. Multi-keuze limiet (profielen)

Parents in MULTI_KEUZE_PARENTS (momenteel {Leg-profiel}) staan meerdere kind-keuzes tegelijk toe, met een gesommeerde limiet op het parent-aantal. De Keuze-modal toont per rij een aantal-input + ± knoppen en een live usage-banner.

parent_aantal = sel(parent.productCode).aantal // bv. Leg-profiel = 5 // Bij elke kind-pick of aantal-wijziging: used_by_others = Σ sel.aantal voor kinderen ≠ huidige rij remaining = max(0, parent_aantal − used_by_others) aantal_clamped = min(invoer, remaining) // Default bij nieuwe pick (i.p.v. suggestAantal die parent_aantal teruggaf): default_aantal = min(suggestAantal(child), remaining) // Aantal = 0 ⇒ pickArtikel toggelt off (verwijdert kind uit cart)

Voorbeeld — Leg-profiel parent met aantal=5:

6. Stuks-display voor plinten/profielen

Achter de schermen wordt sel.aantal voor plinten opgeslagen in (12 m¹ × €7,95 = €95,40). Voor klant-display zet formatAantalStuks(art, aantal) dit om naar stuks:

if (pakLengte > 0 && maten ∈ {m1, m², m2}): stuks = round(aantal / pakLengte) return "5 stuks à 2,4m (12 m¹)" else: return "12 m¹"

Geldt voor:

Klant ziet "5 stuks à 2,4m (12 m¹)" en betaalt voor 12 m¹ — geen confusie tussen stuks-prijs en m¹-prijs.

7. Producten-tabel (snijverlies + pak-rounding)

De Producten-tabel toont fysieke SKU's. Welke regels horen daar thuis? isFysiekProduct(art) matcht:

Voor Vloeren-records komt daar pak-rounding bovenop:

aantal = sel.aantal // m² ingevoerd door klant factor = snijFactor(v) // §4: omrekenfactor > snijverlies-veld > 1.10 m2PerPak = art.pakgrootte || 0 // niet altijd gevuld in Zoho inclSnij = aantal × factor // m² incl. snijverlies pakken = m2PerPak > 0 ? ceil(inclSnij / m2PerPak) : null teLeveren = m2PerPak > 0 ? pakken × m2PerPak : inclSnij regelTotaal = prijsPerUnit × teLeveren // m² × €/m² = totaal // Plinten/profielen/kit (geen Vloeren-record, dus geen pak-rounding): teLeveren = sel.aantal // snij + pak-rounding zit al in suggestAantal regelTotaal = prijsPerUnit × sel.aantal
Kolom in tabelVeldOpmerking
Artikelart.naam + art.productCodevolledige Zoho-naam
Prijs/u€${prijsPerUnit} / m²per eenheid
Aantal incl. snij${teLeveren} m²
incl. ${snijverliesPct}% snij
wat klant betaalt en geleverd krijgt
Totaal€${regelTotaal}= prijsPerUnit × teLeveren
Restpartij: producten waarvan klant de hele restpartij koopt (apart attribuut). Prijs is vast (1 × prijsPerUnit), geen snijverlies-toepassing.

8. Order-overzicht totalen

Het rechter-paneel toont per ruimte een Producten- en Service-totaal, plus globale Project-aggregaties.

Per ruimte

ruimte_producten_totaal = Σ regelTotaal voor elke vloer-product in deze ruimte ruimte_service_totaal = Σ regelPrijs(sel) voor elke service-record in deze ruimte ruimte_totaal = ruimte_producten_totaal + ruimte_service_totaal

Service-subkoppen

SubkopFilter
Artikelhoofdcategorie === 'Accessoires' (ondervloeren, hulpmaterialen — niet plinten/kit, die staan onder Producten)
(geen sub-header)hoofdcategorie === 'Diensten' && onderdeel !== 'Verwijderen' && !isFysiekProduct(art) — flat als leg-arbeid-bucket
Verwijderenhoofdcategorie === 'Diensten' && onderdeel === 'Verwijderen'

De extra !isFysiekProduct-filter verplaatst plinten, profielen en kit-bussen uit de Service-bucket naar Producten (waar ze conceptueel thuishoren — fysieke SKU's die LAB21 inkoopt).

Project-aggregaties

Vloer-m² overschrijdt ruimte-oppervlakte

Veiligheids-check per ruimte: Σ vloer-product.aantal > r.m2 + 0.01 m² → banner-waarschuwing (zie werking.html §6). Fysiek onmogelijk om meer vloer te leggen dan de ruimte groot is.

9. Uitgewerkte voorbeelden

Voorbeeld A — Hout-verlijmd-LAB21, 60 m² woonkamer

Klant kiest een Albero Parket-product (€99,95/m², omrekenfactor 1.0) + chip Lijm + LAB21 legt. Auto-add:

Ruimte-totaal voorlopig: €2.997 + €5.997 = €8.994. Klant kan plint-aantal verhogen voor kozijnafwerking.

Voorbeeld B — PVC zwevend Klik (klant-zelf legt), 130 m²

Klant kiest een Chablis XL Klik PVC-product met geïntegreerde ondervloer (€56,95/m², omrekenfactor 1.10).

Voorbeeld D — PVC Sensation Xcx-MV88001-7-03, 21 m² (snijverlies 5%)

Klant kiest Xcx-MV88001-7-03 · Sensation PVC Reisa Oak (€29,95/m²). Productdata: snijverlies=5, omrekenfactor=null, pakgrootte=4,46.

Met de oude code (factor 1.10): inclSnij=23.1, pakken=ceil(5.18)=6, teLeveren=26.76 m², totaal=€801,32. Het verschil van €133,43 ontstond doordat de pak-rounding op de 6e in plaats van 5e pak uitkwam.

Voorbeeld E — PVC-lijm-LAB21 op Houten planken (instabiel), 25 m², 2e verdieping

Klant kiest een PVC-lijm-product + chip Lijm + LAB21 legt + basisvloer = Houten planken (instabiel) + Type VV = Geen + VVE = Nee + verdieping = 2e. Cascade:

Bij m² = 60 zou Droogbouw50-99 in plaats van 20-29 worden gekozen (60 × €99,95 = €5.997). Op begane grond (verdieping = "Begane grond") vervalt de Droogbouw-kraan (€799,95).

Voorbeeld F — PVC-lijm-LAB21 op Noppen platen-basisvloer (laagdikte-garantie), 40 m²

Klant kiest een PVC-lijm-product + chip Lijm + LAB21 legt + basisvloer = Noppen platen + Type VV = Geen + VVE = Nee. De basisvloer is een gipsachtige plaat met noppen waar normaal VV-buizen in komen; ook zonder VV is een dikke egalisatielaag nodig om de noppen volledig te verzwijgen. Cascade:

Subtotaal voorbewerking + leg-arbeid: €2.112,00 (excl. vloerproduct + transport). De 11020-rule (leg-vloerverwarm-ega via typeVerwarming) firet hier niet — die vereist typeVerwarming ∈ Gefreesd/Noppen platen, en het user-scenario heeft typeVerwarming = Geen.

Voorbeeld C — Laminaat-LAB21, 45 m², met visgraat-patroon

Klant kiest een Laminaat-product met patroon Visgraat + chip Zwevend + LAB21.

10. Zoho-veld-mapping

Zoho Products → product-record fields (geverifieerd via getFields(Products) op 2026-05-17). Mapping in tools/zoho-pull-catalog.mjsnormalizeProduct().

Zoho-API-naamJS-veldGebruik in berekening
Unit_PriceprijsPerUnitbasis-prijs voor regelPrijs
Usage_Unitmatenm¹/m²/Stuk/Koker/Bus — bepaalt aantal-bron + display
Omrekenfactoromrekenfactorsnijverlies-factor voor vloer-producten
Snijverliessnijverliessnijverlies-percentage (display) — 5 / 10 / 15
Pakgroottepakgrootteinfo-veld (m² per pak — display in vloerdetails)
StrokenstrokenPerPakinfo-veld (formula in Zoho)
Manufacturermerkfilter vereistMerk + display
Categorie_1subcategoriefilter — vloertype Hout/Laminaat/PVC
Product_Categoryhoofdcategoriefilter — Vloeren/Accessoires/Diensten
Laying_Methodeinstallatiefilter — Zwevend/Gelijmd/Keuze
Legpatroon_2patroonfilter patroon + uitsluitPatroon
Ge_ntegreerde_ondervloergeintegreerdeOndervloerfilter Ja/Nee voor klik-PVC
Type_verbindingtypeVerbindinginfo-veld
Lengte_in_mm / Breedte_in_mm / Dikte_in_mmlengtemm / breedtemm / diktemmafmetingen-display + hoge-plint filter-funnel
Rd_waarde_m_K_WrdWaardeinfo-veld
Geschikt_voor_vloerverwarminggeschiktVloerverwarminginfo-veld
Kleur (multi)kleurAlgemeendisplay Uiterlijk/Afwerking
Trendstrendsdisplay Uiterlijk/Afwerking
Afwerkingen / Sortering / Gebruiksklasse / Gebruiksklasse_laminaat / Behandelingdiverse hout/PVC/laminaat-specifieke veldendisplay Vloerdetails
V_groeven / FSC_keurmerk / Waterbestendigheid / Antislip / Garantie_Jaar / dB_norm / Toplaag_in_mm / Geschikt_voor_trap / Kitinfo-veldendisplay Vloerdetails
Verwerkingslogicaverwerkingslogicadisplay Afmetingen-sectie

Sub-flow records (gegenereerd uit TSV)

Voorbereiden-records komen uit tools/voorbereiden_user_spec.tsv via generate-voorbereiden.py. 28 TSV-kolommen, mapping:

11. Order-niveau verzendkosten

Sinds 20260531n wordt de transport-keuze order-breed gemaakt in sectie 5 (niet meer per ruimte; state.orderInfo.levering als veld in Relatie-/Ordergegevens is geschrapt). State: state.orderInfo.verzendkosten. Engine: syncVerzendkostenOrder().

Keuze in cardRecord op eerste ruimteBedragAfgeleide orderInfo.levering
AfhaalAfhAfoort (marker, geen prijs)€0Afhalen Amersfoort
Standaard verzendkostenTP€100Bezorging
BinnenbrengserviceBgbezorging / Etage1/2/3-levering (zie tier-tabel)€400 / €500 / €600 / €700Bezorging

Binnenbrengservice — tier-keuze door pickBinnenbrengenEtage()

Tarief volgt twee bron-signalen: state.orderInfo.lift en state.orderInfo.aantalVerdiepingen. Bij ≥ 5 verdiepingen zonder lift is er geen standaardservice; de UI toont een melding "wij hebben geen standaardservice voor deze situatie".

LiftAantal verdiepingenRecordBedrag
Ja(alle)Bgbezorging€400
Nee1Bgbezorging€400
Nee2Etage1-levering€500
Nee3Etage2-levering€600
Nee4Etage3-levering€700
Nee≥ 5— (melding speciale-service)

Default-keuze + gating

De card verschijnt pas wanneer allRuimtesIngevuld() waar is (alle ruimtes hebben m²). Default-keuze = binnenbrengservice; auto-gezet bij eerste render. Klant kan ook handmatig naar Afhaal of Standaard verzendkosten wisselen — bij elke wisseling wordt het eerder geplaatste record netjes opgeruimd (stripAutoVerzendkosten).

Migratie van per-ruimte naar order-niveau

Bestaande state met per-ruimte r.verzendkosten wordt eenmalig bij init gemigreerd (migrateVerzendkostenToOrderLevel()): meest-voorkomende keuze over alle ruimtes wint, oude per-ruimte velden worden leeggemaakt. Idempotent — draait alleen wanneer state.orderInfo.verzendkosten nog leeg is.