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:
@@ -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
55
app/estimation.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user