Add cost/time estimate card to job detail page

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 <noreply@anthropic.com>
This commit is contained in:
Martin Hohenberg
2026-06-18 22:40:52 +02:00
parent 4a3c370317
commit bc6e06a2e2
4 changed files with 120 additions and 0 deletions

View File

@@ -6,3 +6,5 @@ MYSQL_PASSWORD=
STL_UPLOAD_DIR=/data/stl STL_UPLOAD_DIR=/data/stl
TZ=Europe/Berlin TZ=Europe/Berlin
FILAMENTDB_URL=https://web.filamentdb.orb.local FILAMENTDB_URL=https://web.filamentdb.orb.local
VAT_RATE=19
FILAMENT_PRICE_PER_KG=15

55
app/estimation.py Normal file
View File

@@ -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,
}

View File

@@ -21,6 +21,7 @@ from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig from app.models.pricing import PricingConfig
from app.models.todo import Todo from app.models.todo import Todo
from app.constants import COLORS, MATERIALS 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.filamentdb import available_colors, available_materials, get_stock, spool_counts
from app.stl import get_stl_dir, list_stl_files 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 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: if not job:
raise HTTPException(status_code=404, detail="Job not found") 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 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", { return templates.TemplateResponse("job_detail.html", {
"request": request, "request": request,
"active": "jobs", "active": "jobs",
"job": job, "job": job,
"stl": stl, "stl": stl,
"estimation": estimation,
"printers": db.query(Printer).order_by(Printer.name).all(), "printers": db.query(Printer).order_by(Printer.name).all(),
"brackets": db.query(JobBracket).order_by(JobBracket.name).all(), "brackets": db.query(JobBracket).order_by(JobBracket.name).all(),
"materials": available_materials() or MATERIALS, "materials": available_materials() or MATERIALS,

View File

@@ -111,6 +111,66 @@
</div> </div>
{# ── Estimate ── #}
{% if estimation %}
<div class="card mb-4">
<div class="card-header text-secondary small text-uppercase fw-normal" style="border-color:#21262d">
Estimate
<span class="text-secondary fw-normal ms-2" style="font-size:.7rem">
20 % infill · {{ estimation.price_per_kg }} €/kg · {{ estimation.vat_rate }}% VAT
</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="text-secondary small mb-2">Material &amp; Time</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Volume used</td>
<td class="border-0">{{ estimation.material_ccm }} cm³</td></tr>
<tr><td class="text-secondary">Weight</td>
<td>{{ estimation.weight_g }} g</td></tr>
<tr><td class="text-secondary">Print time</td>
<td>
{% set h = (estimation.print_minutes // 60) %}
{% set m = (estimation.print_minutes % 60) %}
{% if h %}{{ h }}h {% endif %}{{ m }}min
<span class="text-secondary" style="font-size:.75rem">(est.)</span>
</td></tr>
</tbody>
</table>
</div>
<div class="col-md-4">
<div class="text-secondary small mb-2">Production cost</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Filament</td>
<td class="border-0">{{ "%.2f"|format(estimation.filament_cost) }} €</td></tr>
<tr><td class="text-secondary">+ 20 % misprint</td>
<td>{{ "%.2f"|format(estimation.filament_cost * 1.2) }} €</td></tr>
</tbody>
</table>
</div>
<div class="col-md-4">
<div class="text-secondary small mb-2">Suggested price</div>
<table class="table table-dark table-sm mb-0" style="font-size:.875rem">
<tbody>
<tr><td class="text-secondary border-0">Net (ex. VAT)</td>
<td class="border-0 fw-medium text-white">{{ "%.2f"|format(estimation.net_price) }} €</td></tr>
<tr><td class="text-secondary">Gross ({{ estimation.vat_rate }}% VAT)</td>
<td class="fw-medium text-white">{{ "%.2f"|format(estimation.gross_price) }} €</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{# ── Log ── #} {# ── Log ── #}
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<h6 class="text-white mb-0">Log</h6> <h6 class="text-white mb-0">Log</h6>