This document describes the actual implementation (sources in parentheses):
- model —
backend/app/models.py(TechnologyMaterial,TechnologyOperation,TechnologyParameter,TechnologyRule,ProductConfiguration); - API and validators —
backend/app/products.py(component_spec,operation_spec,parameter_spec,v_condition); - calculation —
products.plan_components,cost_components,cost_operations,resolve_parameters,matches; - UI —
frontend/src/products.tsx(ProductAdmin,VersionEditor), in-app help —frontend/src/technologyHelp.tsx.
1. How editing works now #
- A Product type (e.g. Lightbox) contains Technologies (e.g. Light Box · profile construction).
- A technology has versions (v1, v2, …). The current version (status
PUBLISHED) is edited directly, without Create draft.
A normal edit does not create a new version. Archived versions (ARCHIVED) are read-only history. - When an Estimate selects a technology it freezes the definition in
ProductConfiguration.technology_snapshot: components, materials, selector/family, default, waste, condition,
operation route, coefficients, parameters, rules and version settings. Every recalculation of the estimate (Recalculate,
geometry change, LED, parameter edits) uses that snapshot. Material prices and operation rates are
frozen separately (price_snapshots), as before. - Therefore editing a technology does not change existing estimates. New estimates get the edited technology immediately.
An old estimate shows the message “Technology was edited …” and an Update to current technology button —
only that button moves the estimate to the current definition (then Recalculate is needed, as with “Update to vN” before). - Create draft / Publish remain in the API (
POST /api/technologies/{id}/versions,POST /api/technology-versions/{id}/publish)
but are hidden from the normal UI. If a draft already exists, it can be opened in Version history and published.
2. Component fields (Components & materials) #
| Field | Stored as | Meaning |
|---|---|---|
| Component | component_code, an UPPER_SNAKE_CASE string (2–60 chars, regex ^[A-Z][A-Z0-9_]{1,59}$) |
System role code. Used by the calculation: nesting group name, cost row key, references from operations (Component column, MACHINE_TIME driver), from parameters (Component column of component_value/material_family), metrics <code>_sheets and <code>_cut_length_m. Read-only in the UI: renaming a code in use breaks these references. Two components may share a code if their Conditions are mutually exclusive (Lightbox: two BACK). |
| Label | string | Display only (breakdown row, nesting group title). Not used in the calculation. |
| Method | calculation_method, one of NESTED_SHEET, PERIMETER_LENGTH, AREA, LENGTH_INPUT, MANUAL_COST |
How quantity and cost are calculated (section 3). |
| Materials (selector value) | available_materials: list of {"material_id": "<canonical material id>", "value": <number or null>} |
Allowed materials and their selector value (section 4). |
| Selector | selector_parameter: parameter key or empty |
Estimate parameter whose value selects the material (section 5). |
| Family | family_parameter: parameter key or empty |
Estimate parameter whose value is a material name (filter, section 5). |
| Default | default_value: number or empty |
Selector value used when the selector parameter is empty (section 4). |
| Waste × | waste_factor: number 0.5…5 |
Quantity multiplier for PERIMETER_LENGTH, AREA, LENGTH_INPUT (section 6). |
| Quantity parameter (formerly “Qty param”) | quantity_parameter: key or empty |
Where LENGTH_INPUT reads metres and MANUAL_COST reads the amount (section 6). |
| Category | cost_category: MATERIALS, MACHINE, LABOUR, OUTSOURCING, LOGISTICS, OPTIONS |
Breakdown group. In estimate totals MATERIALS → Material; MACHINE → cut cost column; the rest → other operations; MACHINE plus the rest = Operations. |
| Condition | condition: JSON object |
When the row applies (section 7). {} = always. |
| Active | boolean | An inactive row is skipped entirely by the calculation. |
3. Methods (METHODS in products.py) #
| Method | What it calculates | Requires | Quantity |
|---|---|---|---|
NESTED_SHEET |
Nesting of the product geometry on sheets of the selected material (one nesting group per component code, sheet size taken from the material). Cost = charged area × price per m² (a SHEET price is divided by sheet area, an M2 price is used as is). |
Material with sheet dimensions and a SHEET or M2 price. |
Charged m² from the nesting result (sheets minus remnants returned to stock). Waste is not applied. |
PERIMETER_LENGTH |
Total perimeter of the product geometry (Lightbox: 2W + 2H per box × quantity). | Material priced per METER. |
perimeter, m × Waste. |
AREA |
Total area of the product geometry. | Material priced per M2. |
area, m² × Waste. |
LENGTH_INPUT |
Length entered in the estimate (e.g. frame profile). | Quantity parameter (metres), material per METER. |
parameter value × Waste (empty → 0). |
MANUAL_COST |
A money amount (e.g. a supplier quote; Lightbox LED = modules × module price). | Quantity parameter (EUR). No material needed. | amount = parameter value; empty/0 → “no cost entered” warning. |
If the material’s price unit does not fit the method (e.g. a per-metre price for NESTED_SHEET), the estimate shows a warning.
4. Materials (selector value) and material selection #
Each tag is an entry {"material_id": "...", "value": ...}. material_id is the id of a canonical material
(Settings → Materials). In the UI a tag reads <name> — <thickness> mm · <sheet>; the number after → is the selector value.
Disabled materials are skipped (component_entries).
Algorithm for choosing one material (plan_components), strictly in order:
- If Family is set and the family parameter has a value — keep only materials whose
nameequals that value. - If Selector is set and its parameter has a value — take the material with an equal selector value
(numbers compare numerically:3=3.0). - Otherwise, if the selector value is empty and Default is set — take the material whose selector value = Default.
- Otherwise (selector value empty) — take the first material in the list.
If the selector parameter has a value but nothing matches, no material is selected and the estimate warns
“no material configured for the selected options”.
Important. Several materials without a selector value and without a Selector mean: the first one is always used.
Lightbox BACK · Aluminium is currently set up like this: Aluminium 1.50 mm and Aluminium 2.00 mm have no values —
1.50 mm is always taken. To make the thickness selectable, give the entries values1.5and2, create a parameter
(e.g.back_thickness_mm, typecomponent_value, ComponentBACK) and set it as Selector.
5. Selector and Family #
Selector is the key (lower_snake_case) of an estimate parameter of this technology, e.g. face_thickness_mm.
Its current value in the estimate (manual choice → Auto rule value → parameter default) is compared
with the materials’ selector values. A component_value parameter whose Component column = this component
automatically offers its materials’ selector values in the estimate (this is how “Face thickness 3 / 4 / 5 mm” appears).
Family is the key of a material_family parameter whose value is a material name,
e.g. face_material = "PMMA opal". It narrows the list to one family, so
“PMMA opal 3 mm” and “PMMA clear 3 mm” can share the selector value 3.
An empty Selector/Family is simply not used. The grey word family in an empty field is a placeholder, not a value.
The UI suggests this technology’s parameter keys (datalist); only lower_snake_case can be entered.
6. Waste and Quantity parameter #
Waste × multiplies the measured quantity before multiplying by the price; only for PERIMETER_LENGTH, AREA,
LENGTH_INPUT. Example: perimeter 10 m, Waste 1.05 → 10.5 m × 3.40 €/m = 35.70 €. 1 = no allowance.
NESTED_SHEET ignores it — nesting already gives real consumption and remnants; so does MANUAL_COST.
Quantity parameter is an estimate parameter key. LENGTH_INPUT reads metres from it, MANUAL_COST reads an amount in EUR.
Other methods ignore it. Empty: LENGTH_INPUT = 0, MANUAL_COST = 0 with a warning.
Examples: frame_length_m (Channel Letters frame), led_cost (supplier amount; for Lightbox — modules × module price,
computed by the LED layout).
6a. Operation route and rates #
A technology stores no rates. Each route step references an operation from Settings → Operations
(chosen from a list), and the rate always comes from that operation (operation.rate + operation.unit).
Need a different rate — choose or create an operation with that rate. The Rate override field was removed from the editor
and the API rejects it. Old values remain only in archived versions and frozen estimates.
| Operation unit | Rate means | For which steps |
|---|---|---|
HOUR (or pricing per_hour) |
€ per hour | time steps: Min / unit and/or Fixed min filled in |
MINUTE |
€ per minute (shown as €/h × 60) | time steps |
METER, M2, PIECE, SHEET, TRIP, FIXED |
€ per unit | per-unit steps: Min / unit and Fixed min empty |
Time step (Min / unit or Fixed min filled in, or the PER_MINUTE driver):
minutes = Fixed min + (Quantity source × Coefficient) × Min / unit, cost = minutes ÷ 60 × the operation’s €/h.
An empty Min / unit counts as 1 minute per unit of the Quantity source; for purely fixed time enter 0.
Per-unit step (both fields empty): cost = Quantity source × Coefficient × operation rate.
Light Box example, assembly 45 minutes per box: Quantity source box_count, Min / unit 45, Fixed min 0;
45 minutes for the whole order: Min / unit 0, Fixed min 45. The rate belongs to the “Assemble 4 corners” operation in Settings → Operations
(Unit HOUR, Rate e.g. 21).
The driver name for such steps is just a label. MACHINE_TIME (CNC feed from the material’s machine profile)
and the Channel Letters drivers (BODY_ASSEMBLY, PVC_TRIM_GLUING, ASSEMBLE_ON_BASE, CLOSE_FACE) have their own formulas,
but they also use the operation’s hourly rate. If a time step references a non-hourly operation,
the calculation and the editor show a warning (previously it silently produced 0).
Migration f7b9d1e3a5c4 moved existing Rate overrides into operations without changing cost: those equal to the operation
rate were removed; differing ones had their route switched to a separate operation with that rate
(e.g. “LED installation · Aluminium / Powder Coated”, 21 €/h). Light Box operations became hourly
(painting and UV — per m²).
7. Condition #
Evaluated by products.matches(condition, values); values are the estimate’s parameter values by key.
Operation Conditions and parameters’ Visible if use the same syntax. The UI and API validate the syntax
and refuse to save an invalid condition.
| Need | Write | Meaning |
|---|---|---|
| always | {} |
no condition |
| one value | {"back_material": "ACP"} |
equals "ACP" |
| boolean | {"frame": true} |
Frame field = Yes |
| number | {"profile_mm": 100} |
equals 100 (100 = 100.0) |
| one of | {"graphics": {"in": ["CUT_VINYL", "PRINTED_VINYL"]}} |
value in the list |
| none of | {"painting_mode": {"not_in": ["NONE"]}} |
value not in the list |
| not equal | {"painting_mode": {"ne": "NONE"}} |
differs ("eq" — equals) |
| several (AND) | {"frame": true, "frame_paint": true} |
all conditions at once |
| no value | {"led_cost": null} |
value is null; a missing or hidden (Visible if) field is null |
Not supported: OR across different keys, <, >, ranges, formulas. Unknown operators
(e.g. "gt") used to be silently ignored — they are now rejected with an error. Keys must be parameters of this technology.
Comparison is type-aware: "100" (string) ≠ 100 (number), "true" ≠ true. Write the value the way the field stores it:
choice → string, Yes/No → true/false, numeric fields → number.
8. Syntax by field #
| Field | Expected type | Correct | Wrong |
|---|---|---|---|
| Component | code | FACE |
face, "FACE" |
| Label | text | Face · PMMA opal |
(empty) |
| Material selector value | number or empty | 3, 1.5 |
"3", 3 mm |
| Selector / Family | key or empty | face_thickness_mm |
Face thickness, {face_thickness_mm} |
| Default | number or empty | 3 |
"3", [3], {"value":3} |
| Waste × | number 0.5–5 | 1.05 |
5%, 105 |
| Quantity parameter | key or empty | frame_length_m |
frame length |
| Condition / Visible if | JSON object | {"letter_depth_mm": 100} |
letter_depth_mm=100, {'frame': true}, [], "" |
| Parameter Default (Estimate fields) | JSON object with "value" |
{"value": 3}, {"value": "ACP"} |
3 |
| Parameter Options | JSON array of objects | [{"value": "ACP", "label": "ACP"}] |
ACP, ALUMINIUM |
JSON rules:
{ }is an object (key → value pairs),[ ]is a list. A Condition is always an object; lists only insidein/not_in.- Keys and strings in double quotes:
"ACP". Single quotes'ACP'are invalid JSON. - Numbers without quotes, with a dot:
100,1.5. - Booleans lowercase without quotes:
true/false; empty value —null. - Pairs and list items are separated by commas, no trailing comma:
{"a": 1, "b": 2}. - An empty Condition field is saved as
{}(Always).""and[]are rejected.
9. Examples from current technologies #
Channel Letters · Aluminium / Powder Coated · FACE
| Component | Method | Materials | Selector | Family | Default | Waste | Quantity parameter | Condition |
|---|---|---|---|---|---|---|---|---|
FACE |
NESTED_SHEET |
PMMA opal 3/4/5 mm → 3, 4, 5; PMMA clear 3/5 mm → 3, 5 | face_thickness_mm |
face_material |
3 |
1 |
— | {} |
This row means: the face panel is nested on sheets; the estimate chooses the family (face_material, e.g.
“PMMA opal”) and thickness (face_thickness_mm); the engine keeps PMMA opal and takes the entry with the selected thickness;
with no choice — 3 mm; always applies.
Channel Letters · Aluminium / Powder Coated · RETURN
RETURN |
PERIMETER_LENGTH |
Aluminium return coil 60 mm → 60; 100 mm → 100 | return_depth_mm |
— | 60 |
1.05 |
— | {} |
|---|---|---|---|---|---|---|---|---|
This row means: the return is bought by the running metre — letter perimeter × 1.05; the letter depth (return_depth_mm,
often set by a height rule) selects the 60 or 100 mm coil.
Lightbox · Light Box · profile construction · FACE
FACE |
NESTED_SHEET |
PMMA opal 3/4/5 mm → 3, 4, 5 | face_thickness_mm |
— | 3 |
1 |
— | {} |
|---|---|---|---|---|---|---|---|---|
This row means: one face panel per box (Width × Height) is nested on PMMA opal sheets;
the thickness from the estimate selects 3, 4 or 5 mm, default 3 mm.
Lightbox · Light Box · profile construction · BACK (two rows)
| Component | Method | Materials | Selector | Family | Default | Waste | Quantity parameter | Condition |
|---|---|---|---|---|---|---|---|---|
BACK |
NESTED_SHEET |
ACP 3.00 mm | — | — | — | 1 |
— | {"back_material": "ACP"} |
BACK |
NESTED_SHEET |
Aluminium 1.50 mm, Aluminium 2.00 mm (no values) | — | — | — | 1 |
— | {"back_material": "ALUMINIUM"} |
These rows mean: exactly one BACK row applies depending on the Back material field; both use the code BACK,
so the nesting group is always called BACK; the aluminium row has no Selector, so the first
material — Aluminium 1.50 mm — is always taken.
Channel Letters · Aluminium / Powder Coated · FRAME
FRAME |
LENGTH_INPUT |
Aluminium frame profile 40×20 | — | — | — | 1.1 |
frame_length_m |
{"frame": true} |
|---|---|---|---|---|---|---|---|---|
This row means: only if the frame is enabled in the estimate — the entered frame length (frame_length_m) × 1.1 is bought
as frame profile per metre and shown in the Options group.