From bc6e06a2e2fa79659fa50eb95170930eef6d220e Mon Sep 17 00:00:00 2001 From: Martin Hohenberg Date: Thu, 18 Jun 2026 22:40:52 +0200 Subject: [PATCH] Add cost/time estimate card to job detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/estimation.py: - 20% infill → material volume → weight (density per material) - print time at 20 g/h (conservative FDM average) - filament cost at FILAMENT_PRICE_PER_KG EUR/kg (env, default 15) - +20% misprint buffer, +30% gross profit margin - net price (ex. VAT) and gross price (VAT_RATE %, env, default 19) Card shows three columns: Material & Time / Production cost / Suggested price. Only rendered when STL volume is available. Assumptions shown in card header. Co-Authored-By: Claude Sonnet 4.6 --- .env.install | 2 ++ app/estimation.py | 55 ++++++++++++++++++++++++++++++++ app/routers/ui.py | 3 ++ app/templates/job_detail.html | 60 +++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 app/estimation.py diff --git a/.env.install b/.env.install index b90be90..96b024d 100644 --- a/.env.install +++ b/.env.install @@ -6,3 +6,5 @@ MYSQL_PASSWORD= STL_UPLOAD_DIR=/data/stl TZ=Europe/Berlin FILAMENTDB_URL=https://web.filamentdb.orb.local +VAT_RATE=19 +FILAMENT_PRICE_PER_KG=15 diff --git a/app/estimation.py b/app/estimation.py new file mode 100644 index 0000000..80c274c --- /dev/null +++ b/app/estimation.py @@ -0,0 +1,55 @@ +"""Job cost and time estimation from STL geometry.""" +import os + +# g/cm³ per material — used to convert volume → weight +_DENSITY: dict[str, float] = { + "PLA": 1.24, + "PETG": 1.27, + "ABS": 1.04, + "ASA": 1.07, + "TPU": 1.20, + "Nylon": 1.14, + "PC": 1.20, +} +_DEFAULT_DENSITY = 1.24 + +# Conservative average FDM print speed for weight-based time estimate +_PRINT_SPEED_G_PER_HOUR = 20.0 + + +def estimate(volume_ccm: float, material: str | None) -> dict: + """ + Returns a cost/time estimate dict for one job. + + Assumptions: + - 20 % infill → material used = volume * 0.20 + - filament density from _DENSITY (default 1.24 g/cm³) + - print speed: 20 g/h (conservative FDM average) + - filament cost: FILAMENT_PRICE_PER_KG EUR / kg (env, default 15) + - misprint buffer: +20 % + - gross profit margin: +30 % + - VAT: VAT_RATE % (env, default 19) + """ + vat_rate = float(os.environ.get("VAT_RATE", 19)) + price_per_kg = float(os.environ.get("FILAMENT_PRICE_PER_KG", 15)) + density = _DENSITY.get(material or "", _DEFAULT_DENSITY) + + material_ccm = volume_ccm * 0.20 + weight_g = material_ccm * density + print_minutes = round((weight_g / _PRINT_SPEED_G_PER_HOUR) * 60) + + filament_cost = weight_g / 1000 * price_per_kg + after_misprint = filament_cost * 1.20 + net_price = after_misprint * 1.30 + gross_price = net_price * (1 + vat_rate / 100) + + return { + "material_ccm": round(material_ccm, 2), + "weight_g": round(weight_g, 1), + "print_minutes": print_minutes, + "filament_cost": round(filament_cost, 2), + "net_price": round(net_price, 2), + "gross_price": round(gross_price, 2), + "vat_rate": vat_rate, + "price_per_kg": price_per_kg, + } diff --git a/app/routers/ui.py b/app/routers/ui.py index 891ba38..3bfde9e 100644 --- a/app/routers/ui.py +++ b/app/routers/ui.py @@ -21,6 +21,7 @@ from app.models.printer_type import PrinterType from app.models.pricing import PricingConfig from app.models.todo import Todo from app.constants import COLORS, MATERIALS +from app.estimation import estimate from app.filamentdb import available_colors, available_materials, get_stock, spool_counts from app.stl import get_stl_dir, list_stl_files from app.stl_analysis import analyse, get_cache_map # noqa: F401 used in job_detail route @@ -247,11 +248,13 @@ def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)): if not job: raise HTTPException(status_code=404, detail="Job not found") stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None + estimation = estimate(stl.volume_ccm, job.job_material) if stl and stl.volume_ccm else None return templates.TemplateResponse("job_detail.html", { "request": request, "active": "jobs", "job": job, "stl": stl, + "estimation": estimation, "printers": db.query(Printer).order_by(Printer.name).all(), "brackets": db.query(JobBracket).order_by(JobBracket.name).all(), "materials": available_materials() or MATERIALS, diff --git a/app/templates/job_detail.html b/app/templates/job_detail.html index 39f0e8c..c2bbaaa 100644 --- a/app/templates/job_detail.html +++ b/app/templates/job_detail.html @@ -111,6 +111,66 @@ +{# ── Estimate ── #} +{% if estimation %} +
+
+ Estimate + + 20 % infill · {{ estimation.price_per_kg }} €/kg · {{ estimation.vat_rate }}% VAT + +
+
+
+ +
+
Material & Time
+ + + + + + + + + +
Volume used{{ estimation.material_ccm }} cm³
Weight{{ estimation.weight_g }} g
Print time + {% set h = (estimation.print_minutes // 60) %} + {% set m = (estimation.print_minutes % 60) %} + {% if h %}{{ h }}h {% endif %}{{ m }}min + (est.) +
+
+ +
+
Production cost
+ + + + + + + +
Filament{{ "%.2f"|format(estimation.filament_cost) }} €
+ 20 % misprint{{ "%.2f"|format(estimation.filament_cost * 1.2) }} €
+
+ +
+
Suggested price
+ + + + + + + +
Net (ex. VAT){{ "%.2f"|format(estimation.net_price) }} €
Gross ({{ estimation.vat_rate }}% VAT){{ "%.2f"|format(estimation.gross_price) }} €
+
+ +
+
+
+{% endif %} + {# ── Log ── #}
Log