Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil, ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
218 lines
7.6 KiB
Python
218 lines
7.6 KiB
Python
"""Dieselbe Rechnung wie in der Druckkalkulation."""
|
||
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from grotti.time_window import _in_segment, _parse_hm
|
||
|
||
|
||
def calculate(job, material):
|
||
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
|
||
waste = float(job.get("waste_percent") or 0)
|
||
filament_g = part_g * (1 + waste / 100)
|
||
price = float(material.get("price_per_kg") or 0)
|
||
filament_cost = (filament_g / 1000) * price
|
||
util = min(100, max(1, float(job.get("utilization") or 1))) / 100
|
||
density = float(material.get("density") or 0) or 1
|
||
volumetric = float(material.get("volumetric") or 0)
|
||
cm3_per_hour = volumetric * 3.6 * util
|
||
hours = (filament_g / density) / cm3_per_hour if cm3_per_hour else 0
|
||
watts = float(job.get("watts") or 0)
|
||
euro_per_kwh = float(job.get("euro_per_kwh") or 0)
|
||
print_kwh = (watts / 1000) * hours
|
||
dry_kwh = (float(job.get("dryer_watts") or 0) / 1000) * float(job.get("dry_hours") or 0)
|
||
energy_cost = (print_kwh + dry_kwh) * euro_per_kwh
|
||
if job.get("price_mode") == "perKg":
|
||
revenue = (part_g / 1000) * float(job.get("price") or 0)
|
||
else:
|
||
revenue = float(job.get("price") or 0) * max(1, float(job.get("qty") or 1))
|
||
margin = revenue - filament_cost - energy_cost
|
||
per_hour = margin / hours if hours else 0
|
||
return {
|
||
"part_g": part_g,
|
||
"filament_g": filament_g,
|
||
"filament_cost": filament_cost,
|
||
"hours": hours,
|
||
"energy_cost": energy_cost,
|
||
"revenue": revenue,
|
||
"margin": margin,
|
||
"per_hour": per_hour,
|
||
"print_kwh": print_kwh,
|
||
"dry_kwh": dry_kwh,
|
||
}
|
||
|
||
|
||
def _parse_ts(value):
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
return value.timestamp()
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _avg_power(a, b):
|
||
vals = [float(v) for v in (a, b) if isinstance(v, (int, float)) and not isinstance(v, bool)]
|
||
if not vals:
|
||
return 0.0
|
||
return max(0.0, min(1.0, sum(vals) / len(vals)))
|
||
|
||
|
||
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
|
||
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben."""
|
||
if not samples or len(samples) < 2:
|
||
return None
|
||
total = 0.0
|
||
ed_steps = 0
|
||
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
|
||
max_dt = 90.0
|
||
for i in range(len(samples) - 1):
|
||
a, b = samples[i], samples[i + 1]
|
||
t0 = _parse_ts(a.get("sampled_at"))
|
||
t1 = _parse_ts(b.get("sampled_at"))
|
||
if t0 is None or t1 is None or t1 <= t0:
|
||
continue
|
||
dt = min(t1 - t0, max_dt)
|
||
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
|
||
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
|
||
has_ed = any(
|
||
isinstance(v, (int, float)) and not isinstance(v, bool)
|
||
for v in (pn0, pn1, pb0, pb1)
|
||
)
|
||
if has_ed:
|
||
ed_steps += 1
|
||
ed_n = _avg_power(pn0, pn1)
|
||
ed_b = _avg_power(pb0, pb1)
|
||
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b
|
||
total += (watts / 1000.0) * (dt / 3600.0)
|
||
if ed_steps < 1:
|
||
return None
|
||
return total
|
||
|
||
|
||
def _is_nt_moment(moment, tariff):
|
||
start_t = _parse_hm(tariff.get("nt_from"))
|
||
end_t = _parse_hm(tariff.get("nt_to"))
|
||
if start_t is None or end_t is None:
|
||
return False
|
||
now_t = moment.timetz().replace(tzinfo=None)
|
||
return _in_segment(now_t, start_t, end_t)
|
||
|
||
|
||
def effective_euro_per_kwh(duration_s, energy, end_dt=None):
|
||
energy = energy or {}
|
||
tariff = energy.get("tariff")
|
||
if not tariff or not duration_s or float(duration_s) <= 0:
|
||
return float(energy.get("euro_per_kwh") or 0)
|
||
end = end_dt if isinstance(end_dt, datetime) else datetime.now(timezone.utc).astimezone()
|
||
if end.tzinfo is None:
|
||
end = end.replace(tzinfo=timezone.utc)
|
||
start = end - timedelta(seconds=float(duration_s))
|
||
step = 300.0
|
||
t = start
|
||
nt_s = 0.0
|
||
total = 0.0
|
||
while t < end:
|
||
chunk = min(step, (end - t).total_seconds())
|
||
if _is_nt_moment(t, tariff):
|
||
nt_s += chunk
|
||
total += chunk
|
||
t += timedelta(seconds=chunk)
|
||
if total <= 0:
|
||
return float(energy.get("euro_per_kwh") or 0)
|
||
nt_frac = nt_s / total
|
||
ht = float(tariff.get("ht_euro") or energy.get("euro_per_kwh") or 0)
|
||
nt = float(tariff.get("nt_euro") or ht)
|
||
return nt_frac * nt + (1.0 - nt_frac) * ht
|
||
|
||
|
||
def energy_estimate(duration_s, energy, samples=None, end_dt=None):
|
||
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
|
||
|
||
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
|
||
"""
|
||
energy = energy or {}
|
||
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
|
||
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
|
||
base_w = float(energy.get("watts") or 0)
|
||
nozzle_w = float(energy.get("nozzle_watts") or 0)
|
||
bed_w = float(energy.get("bed_watts") or 0)
|
||
|
||
print_kwh = None
|
||
source = "flat"
|
||
if (nozzle_w > 0 or bed_w > 0) and samples:
|
||
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w)
|
||
if print_kwh is not None:
|
||
source = "ed"
|
||
|
||
if print_kwh is None:
|
||
hours = float(duration_s or 0) / 3600
|
||
print_kwh = (base_w / 1000) * hours
|
||
source = "flat"
|
||
|
||
kwh = print_kwh + dry_kwh
|
||
return {
|
||
"kwh": kwh,
|
||
"print_kwh": print_kwh,
|
||
"dry_kwh": dry_kwh,
|
||
"cost": kwh * euro,
|
||
"source": source,
|
||
}
|
||
|
||
|
||
def energy_cost(duration_s, energy, samples=None, end_dt=None):
|
||
return energy_estimate(duration_s, energy, samples, end_dt=end_dt)["cost"]
|
||
|
||
|
||
def offer_from_file(file_row, material_price, machine_rate_per_h, setup_min, margin):
|
||
"""Angebots-Schätzung aus Datei-Gewicht und Druckzeit."""
|
||
weight_g = float((file_row or {}).get("weight_g") or 0)
|
||
est_s = float((file_row or {}).get("estimated_s") or 0)
|
||
hours = est_s / 3600.0 if est_s > 0 else 0.0
|
||
mat = float(material_price or 0)
|
||
rate = float(machine_rate_per_h or 0)
|
||
setup_h = float(setup_min or 0) / 60.0
|
||
material_cost = (weight_g / 1000.0) * mat
|
||
machine_cost = hours * rate + setup_h * rate
|
||
subtotal = material_cost + machine_cost
|
||
pct = float(margin or 0)
|
||
total = subtotal * (1.0 + pct / 100.0) if subtotal > 0 else 0.0
|
||
return {
|
||
"weight_g": weight_g,
|
||
"hours": hours,
|
||
"material_cost": material_cost,
|
||
"machine_cost": machine_cost,
|
||
"subtotal": subtotal,
|
||
"margin_percent": pct,
|
||
"total": total,
|
||
}
|
||
|
||
|
||
def order_actuals(conn, order_id):
|
||
"""Summe Kosten, Zeit und Ausschuss der verknüpften Läufe."""
|
||
row = conn.execute(
|
||
"""
|
||
SELECT
|
||
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
|
||
COALESCE(SUM(r.duration_s), 0) AS duration_s,
|
||
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
|
||
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
|
||
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
|
||
FROM order_items oi
|
||
JOIN runs r ON r.order_item_id = oi.id
|
||
WHERE oi.order_id = %s
|
||
""",
|
||
(order_id,),
|
||
).fetchone()
|
||
return {
|
||
"cost_total": float(row["cost_total"] or 0),
|
||
"duration_s": float(row["duration_s"] or 0),
|
||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||
"scrap_g": float(row["scrap_g"] or 0),
|
||
"scrap_runs": int(row["scrap_runs"] or 0),
|
||
}
|