feat: FASE 6.5 - Report PDF (WeasyPrint + Kaleido)

Add PDF report generation for Metrologist dashboard with SPC and
measurement reports including SVG charts, capability indices, and
company logo embedding.

New files:
- server/services/report_service.py (Jinja2 + Plotly/Kaleido + WeasyPrint)
- server/routers/reports.py (2 GET endpoints with auth)
- server/templates/reports/ (base, spc, measurement HTML templates)

Modified:
- server/main.py (register reports router)
- client dashboard (download buttons + proxy routes)
- i18n strings IT/EN

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 15:24:32 +01:00
parent bcd807e57d
commit 26e5b9343d
10 changed files with 1056 additions and 3 deletions
+72 -1
View File
@@ -1,8 +1,11 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies.""" """Metrologist blueprint - SPC statistics dashboard and API proxies."""
from flask import Blueprint, jsonify, render_template, request import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
from flask_babel import gettext as _ from flask_babel import gettext as _
from blueprints.auth import login_required, role_required from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client from services.api_client import api_client
statistics_bp = Blueprint("statistics", __name__) statistics_bp = Blueprint("statistics", __name__)
@@ -105,3 +108,71 @@ def api_subtasks():
return jsonify(resp) return jsonify(resp)
return jsonify(resp) return jsonify(resp)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
def _proxy_report(endpoint: str):
"""Forward query params to a FastAPI reports endpoint and return PDF."""
params = {}
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number",
]:
val = request.args.get(key)
if val is not None and val != "":
params[key] = val
base_url = Config.API_SERVER_URL.rstrip("/")
headers = {}
api_key = session.get("api_key")
if api_key:
headers["X-API-Key"] = api_key
try:
resp = http_requests.get(
f"{base_url}/api/reports/{endpoint}",
headers=headers,
params=params,
timeout=120,
)
except (http_requests.ConnectionError, http_requests.Timeout) as e:
return jsonify({"error": True, "detail": str(e)}), 502
if not resp.ok:
try:
err = resp.json()
detail = err.get("detail", f"HTTP {resp.status_code}")
except Exception:
detail = f"HTTP {resp.status_code}"
return jsonify({"error": True, "detail": detail}), resp.status_code
from flask import Response as FlaskResponse
return FlaskResponse(
resp.content,
mimetype="application/pdf",
headers={
"Content-Disposition": resp.headers.get(
"Content-Disposition", f'attachment; filename="report.pdf"'
)
},
)
@statistics_bp.route("/api/report-spc")
@login_required
@role_required("Metrologist")
def api_report_spc():
"""Proxy: download SPC PDF report."""
return _proxy_report("spc")
@statistics_bp.route("/api/report-measurements")
@login_required
@role_required("Metrologist")
def api_report_measurements():
"""Proxy: download measurements PDF report."""
return _proxy_report("measurements")
+73 -2
View File
@@ -61,8 +61,41 @@
</div> </div>
</div> </div>
<!-- Apply button --> <!-- Apply button + Download -->
<div class="mt-4 flex justify-end"> <div class="mt-4 flex justify-between items-center">
<!-- Download buttons (visible only when data is loaded) -->
<div class="flex gap-2" x-show="hasData" x-cloak>
<button @click="downloadReport('spc')"
:disabled="!selectedSubtaskId || downloadingReport"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'spc'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'spc'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report SPC") }}
</button>
<button @click="downloadReport('measurements')"
:disabled="downloadingReport"
class="px-4 py-2 bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'measurements'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'measurements'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report Misurazioni") }}
</button>
</div>
<div x-show="!hasData"></div>
<button @click="loadData()" <button @click="loadData()"
:disabled="!selectedRecipeId || loading" :disabled="!selectedRecipeId || loading"
class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"> class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
@@ -233,6 +266,7 @@ function spcDashboard() {
loading: false, loading: false,
hasData: false, hasData: false,
errorMessage: '', errorMessage: '',
downloadingReport: false,
init() { init() {
// Nothing to pre-load // Nothing to pre-load
@@ -368,6 +402,43 @@ function spcDashboard() {
if (value >= 1.0) return 'text-amber-600'; if (value >= 1.0) return 'text-amber-600';
return 'text-red-600'; return 'text-red-600';
}, },
async downloadReport(type) {
this.downloadingReport = type;
try {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url);
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
this.errorMessage = errData.detail || '{{ _("Errore nella generazione del report") }}';
return;
}
// Download the PDF
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = type === 'spc'
? 'spc_report_' + this.selectedRecipeId + '.pdf'
: 'measurements_report_' + this.selectedRecipeId + '.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (e) {
console.error('Error downloading report:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
} finally {
this.downloadingReport = false;
}
},
}; };
} }
</script> </script>
@@ -1038,3 +1038,13 @@ msgstr "Normal curve"
msgid "Indici non disponibili" msgid "Indici non disponibili"
msgstr "Indices not available" msgstr "Indices not available"
# Report PDF
msgid "Report SPC"
msgstr "SPC Report"
msgid "Report Misurazioni"
msgstr "Measurements Report"
msgid "Errore nella generazione del report"
msgstr "Error generating report"
@@ -1038,3 +1038,13 @@ msgstr "Curva normale"
msgid "Indici non disponibili" msgid "Indici non disponibili"
msgstr "Indici non disponibili" msgstr "Indici non disponibili"
# Report PDF
msgid "Report SPC"
msgstr "Report SPC"
msgid "Report Misurazioni"
msgstr "Report Misurazioni"
msgid "Errore nella generazione del report"
msgstr "Errore nella generazione del report"
+2
View File
@@ -15,6 +15,7 @@ from routers.tasks import router as tasks_router
from routers.measurements import router as measurements_router from routers.measurements import router as measurements_router
from routers.files import router as files_router from routers.files import router as files_router
from routers.settings import router as settings_router from routers.settings import router as settings_router
from routers.reports import router as reports_router
from routers.statistics import router as statistics_router from routers.statistics import router as statistics_router
@@ -60,6 +61,7 @@ app.include_router(measurements_router)
app.include_router(files_router) app.include_router(files_router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(statistics_router) app.include_router(statistics_router)
app.include_router(reports_router)
@app.get("/api/health") @app.get("/api/health")
+81
View File
@@ -0,0 +1,81 @@
"""Reports router — PDF report generation endpoints for Metrologist."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from middleware.api_key import require_metrologist
from models.user import User
from services.report_service import generate_measurement_report, generate_spc_report
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.get("/spc")
async def download_spc_report(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Generate and download SPC PDF report."""
try:
pdf_bytes = await generate_spc_report(
db, recipe_id, subtask_id,
version_id, date_from, date_to,
operator_id, lot_number, serial_number,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Report generation failed: {str(e)}",
)
filename = f"spc_report_recipe{recipe_id}_st{subtask_id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/measurements")
async def download_measurement_report(
recipe_id: int = Query(...),
subtask_id: int | None = Query(None),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Generate and download measurement table PDF report."""
try:
pdf_bytes = await generate_measurement_report(
db, recipe_id, subtask_id,
version_id, date_from, date_to,
operator_id, lot_number, serial_number,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Report generation failed: {str(e)}",
)
filename = f"measurements_report_recipe{recipe_id}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+429
View File
@@ -0,0 +1,429 @@
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
import base64
from datetime import datetime
from pathlib import Path
import plotly.graph_objects as go
import plotly.io as pio
from jinja2 import Environment, FileSystemLoader
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from weasyprint import HTML
from config import settings
from models.measurement import Measurement
from models.recipe import Recipe, RecipeVersion
from models.setting import SystemSetting
from models.task import RecipeSubtask, RecipeTask
from models.user import User
from services.spc_service import (
compute_capability,
compute_control_chart,
compute_histogram,
compute_summary,
)
# Jinja2 environment for report templates
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
_jinja_env = Environment(
loader=FileSystemLoader(str(_templates_dir)),
autoescape=True,
)
async def _query_measurements(
db: AsyncSession,
recipe_id: int,
version_id: int | None = None,
subtask_id: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
) -> list[Measurement]:
"""Query measurements with common filters (duplicated from statistics router)."""
filters = []
if version_id is not None:
filters.append(Measurement.version_id == version_id)
else:
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if operator_id is not None:
filters.append(Measurement.measured_by == operator_id)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
query = (
select(Measurement)
.where(and_(*filters) if filters else True)
.order_by(Measurement.measured_at.asc())
)
result = await db.execute(query)
return list(result.scalars().all())
async def _get_company_info(db: AsyncSession) -> dict:
"""Read company logo path and company name from system_settings."""
info = {"logo_base64": None, "company_name": "TieMeasureFlow"}
# Company name
result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "company_name")
)
setting = result.scalar_one_or_none()
if setting:
info["company_name"] = setting.setting_value
# Company logo
result = await db.execute(
select(SystemSetting).where(SystemSetting.setting_key == "company_logo_path")
)
setting = result.scalar_one_or_none()
if setting and setting.setting_value:
logo_path = settings.upload_path / setting.setting_value
if logo_path.exists():
with open(logo_path, "rb") as f:
logo_bytes = f.read()
# Detect mime type
suffix = logo_path.suffix.lower()
mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml"}.get(suffix, "image/png")
info["logo_base64"] = f"data:{mime};base64,{base64.b64encode(logo_bytes).decode()}"
return info
async def _get_recipe_info(db: AsyncSession, recipe_id: int) -> dict:
"""Get recipe code and name."""
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
recipe = result.scalar_one_or_none()
if recipe:
return {"code": recipe.code, "name": recipe.name, "id": recipe.id}
return {"code": "???", "name": "Unknown", "id": recipe_id}
async def _get_subtask_info(db: AsyncSession, subtask_id: int) -> dict | None:
"""Get subtask details including tolerances."""
result = await db.execute(
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
)
st = result.scalar_one_or_none()
if st is None:
return None
return {
"id": st.id,
"marker_number": st.marker_number,
"description": st.description,
"unit": st.unit,
"nominal": float(st.nominal) if st.nominal is not None else None,
"utl": float(st.utl) if st.utl is not None else None,
"uwl": float(st.uwl) if st.uwl is not None else None,
"lwl": float(st.lwl) if st.lwl is not None else None,
"ltl": float(st.ltl) if st.ltl is not None else None,
}
async def _get_operator_map(db: AsyncSession, user_ids: set[int]) -> dict[int, str]:
"""Build a map of user_id -> display name."""
if not user_ids:
return {}
result = await db.execute(select(User).where(User.id.in_(user_ids)))
users = result.scalars().all()
return {u.id: u.display_name or u.username for u in users}
def _generate_control_chart_svg(chart_data, subtask_info: dict | None) -> str:
"""Generate control chart SVG using Plotly + Kaleido."""
if not chart_data.values:
return ""
fig = go.Figure()
# Measurement values
x_vals = list(range(1, len(chart_data.values) + 1))
fig.add_trace(go.Scatter(
x=x_vals, y=chart_data.values,
mode="lines+markers",
name="Values",
line=dict(color="#2563EB", width=2),
marker=dict(size=5),
))
# Mean line
fig.add_hline(y=chart_data.mean, line=dict(color="#64748B", dash="dash", width=1.5),
annotation_text=f"Mean: {chart_data.mean:.4f}")
# UCL / LCL
fig.add_hline(y=chart_data.ucl, line=dict(color="#EF4444", dash="dot", width=1),
annotation_text=f"UCL: {chart_data.ucl:.4f}")
fig.add_hline(y=chart_data.lcl, line=dict(color="#EF4444", dash="dot", width=1),
annotation_text=f"LCL: {chart_data.lcl:.4f}")
# Tolerance limits
if chart_data.utl is not None:
fig.add_hline(y=chart_data.utl, line=dict(color="#DC2626", width=1.5),
annotation_text=f"UTL: {chart_data.utl}")
if chart_data.ltl is not None:
fig.add_hline(y=chart_data.ltl, line=dict(color="#DC2626", width=1.5),
annotation_text=f"LTL: {chart_data.ltl}")
# Out of control points
if chart_data.out_of_control:
ooc_x = [x_vals[i] for i in chart_data.out_of_control]
ooc_y = [chart_data.values[i] for i in chart_data.out_of_control]
fig.add_trace(go.Scatter(
x=ooc_x, y=ooc_y,
mode="markers",
name="Out of control",
marker=dict(color="#EF4444", size=10, symbol="x"),
))
fig.update_layout(
title="Control Chart",
xaxis_title="Measurement #",
yaxis_title="Value",
template="plotly_white",
width=700, height=350,
margin=dict(l=60, r=30, t=40, b=40),
showlegend=False,
)
svg_bytes = pio.to_image(fig, format="svg", engine="kaleido")
return svg_bytes.decode("utf-8")
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
"""Generate histogram SVG using Plotly + Kaleido."""
if not hist_data.bins:
return ""
fig = go.Figure()
# Histogram bars
bin_centers = [(hist_data.bins[i] + hist_data.bins[i + 1]) / 2 for i in range(len(hist_data.counts))]
bin_width = hist_data.bins[1] - hist_data.bins[0] if len(hist_data.bins) > 1 else 1
fig.add_trace(go.Bar(
x=bin_centers, y=hist_data.counts,
width=bin_width * 0.9,
marker_color="#2563EB",
opacity=0.7,
name="Frequency",
))
# Normal curve overlay
if hist_data.normal_x and hist_data.normal_y:
fig.add_trace(go.Scatter(
x=hist_data.normal_x, y=hist_data.normal_y,
mode="lines",
name="Normal curve",
line=dict(color="#EF4444", width=2),
))
# Tolerance lines
if subtask_info:
if subtask_info.get("utl") is not None:
fig.add_vline(x=subtask_info["utl"], line=dict(color="#DC2626", width=1.5, dash="dash"),
annotation_text="UTL")
if subtask_info.get("ltl") is not None:
fig.add_vline(x=subtask_info["ltl"], line=dict(color="#DC2626", width=1.5, dash="dash"),
annotation_text="LTL")
if subtask_info.get("nominal") is not None:
fig.add_vline(x=subtask_info["nominal"], line=dict(color="#22C55E", width=1.5),
annotation_text="Nom")
fig.update_layout(
title="Histogram",
xaxis_title="Value",
yaxis_title="Frequency",
template="plotly_white",
width=700, height=350,
margin=dict(l=60, r=30, t=40, b=40),
bargap=0.05,
showlegend=False,
)
svg_bytes = pio.to_image(fig, format="svg", engine="kaleido")
return svg_bytes.decode("utf-8")
async def generate_spc_report(
db: AsyncSession,
recipe_id: int,
subtask_id: int,
version_id: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
) -> bytes:
"""Generate SPC PDF report and return bytes.
Includes: summary, capability indices, control chart SVG, histogram SVG.
"""
# 1. Query measurements
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
)
# 2. Compute SPC data
pass_fail_values = [m.pass_fail for m in measurements]
values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements]
summary = compute_summary(pass_fail_values)
subtask_info = await _get_subtask_info(db, subtask_id)
tol_utl = subtask_info["utl"] if subtask_info else None
tol_ltl = subtask_info["ltl"] if subtask_info else None
tol_uwl = subtask_info["uwl"] if subtask_info else None
tol_lwl = subtask_info["lwl"] if subtask_info else None
tol_nominal = subtask_info["nominal"] if subtask_info else None
capability = compute_capability(values, tol_utl, tol_ltl, tol_nominal)
control_chart = compute_control_chart(
values, timestamps, tol_utl, tol_uwl, tol_lwl, tol_ltl, tol_nominal
)
histogram = compute_histogram(values)
# 3. Generate SVG charts
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
# 4. Get company info and recipe info
company = await _get_company_info(db)
recipe_info = await _get_recipe_info(db, recipe_id)
# 5. Build filter description
filters_desc = []
if version_id:
filters_desc.append(f"Version: {version_id}")
if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
if lot_number:
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
filters_desc.append(f"Serial: {serial_number}")
# 6. Render HTML template
template = _jinja_env.get_template("spc_report.html")
html_content = template.render(
company=company,
recipe=recipe_info,
subtask=subtask_info,
summary=summary,
capability=capability,
control_chart_svg=control_chart_svg,
histogram_svg=histogram_svg,
filters_desc=filters_desc,
generated_at=datetime.now(),
n_measurements=len(measurements),
)
# 7. Convert HTML to PDF
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
async def generate_measurement_report(
db: AsyncSession,
recipe_id: int,
subtask_id: int | None = None,
version_id: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
) -> bytes:
"""Generate measurement table PDF report and return bytes."""
# 1. Query measurements
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
)
# 2. Get recipe info
recipe_info = await _get_recipe_info(db, recipe_id)
company = await _get_company_info(db)
# 3. Get subtask map and operator map
subtask_ids = {m.subtask_id for m in measurements}
operator_ids = {m.measured_by for m in measurements}
subtask_map = {}
for sid in subtask_ids:
info = await _get_subtask_info(db, sid)
if info:
subtask_map[sid] = info
operator_map = await _get_operator_map(db, operator_ids)
# 4. Build table rows
rows = []
for i, m in enumerate(measurements, 1):
st_info = subtask_map.get(m.subtask_id, {})
rows.append({
"num": i,
"subtask": f"#{st_info.get('marker_number', '?')}{st_info.get('description', '?')}",
"value": f"{float(m.value):.4f}",
"unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail,
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
"lot_number": m.lot_number or "",
"serial_number": m.serial_number or "",
})
# 5. Summary
pass_fail_values = [m.pass_fail for m in measurements]
summary = compute_summary(pass_fail_values)
# 6. Build filter description
filters_desc = []
if subtask_id:
si = subtask_map.get(subtask_id)
if si:
filters_desc.append(f"Point: #{si['marker_number']}{si['description']}")
if version_id:
filters_desc.append(f"Version: {version_id}")
if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
if lot_number:
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
filters_desc.append(f"Serial: {serial_number}")
# 7. Render template
template = _jinja_env.get_template("measurement_report.html")
html_content = template.render(
company=company,
recipe=recipe_info,
rows=rows,
summary=summary,
filters_desc=filters_desc,
generated_at=datetime.now(),
n_measurements=len(measurements),
)
# 8. Convert to PDF
pdf_bytes = HTML(string=html_content).write_pdf()
return pdf_bytes
+190
View File
@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4;
margin: 2cm 1.5cm;
@bottom-center {
content: "Page " counter(page) " / " counter(pages);
font-family: 'Inter', sans-serif;
font-size: 8pt;
color: #64748B;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', 'Helvetica Neue', Arial, sans-serif;
font-size: 10pt;
color: #1e293b;
line-height: 1.5;
}
.mono { font-family: 'JetBrains Mono', 'Courier New', monospace; }
/* Header */
.report-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid #2563EB;
padding-bottom: 12px;
margin-bottom: 20px;
}
.report-header .logo-area {
display: flex;
align-items: center;
gap: 12px;
}
.report-header .logo-area img {
max-height: 50px;
max-width: 120px;
}
.report-header .company-name {
font-size: 14pt;
font-weight: 700;
color: #2563EB;
}
.report-header .report-title {
font-size: 11pt;
font-weight: 600;
color: #64748B;
text-align: right;
}
/* Footer */
.report-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
text-align: center;
font-size: 7pt;
color: #94a3b8;
padding-top: 8px;
border-top: 1px solid #e2e8f0;
}
/* Section */
.section { margin-bottom: 16px; }
.section-title {
font-size: 11pt;
font-weight: 700;
color: #2563EB;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 4px;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
}
th {
background-color: #2563EB;
color: white;
font-weight: 600;
text-align: left;
padding: 6px 8px;
}
td {
padding: 5px 8px;
border-bottom: 1px solid #e2e8f0;
}
tr:nth-child(even) td { background-color: #f8fafc; }
/* Badge colors */
.pass { color: #059669; font-weight: 600; }
.warning { color: #d97706; font-weight: 600; }
.fail { color: #dc2626; font-weight: 600; }
.pass-bg { background-color: #ecfdf5; }
.warning-bg { background-color: #fffbeb; }
.fail-bg { background-color: #fef2f2; }
/* Capability colors */
.cap-good { color: #059669; }
.cap-marginal { color: #d97706; }
.cap-poor { color: #dc2626; }
/* Filters info box */
.info-box {
background-color: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 12px;
font-size: 9pt;
}
.info-box .label { font-weight: 600; color: #64748B; }
/* Chart container */
.chart-container {
text-align: center;
margin: 10px 0;
page-break-inside: avoid;
}
.chart-container svg {
max-width: 100%;
height: auto;
}
/* Summary cards */
.summary-grid {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.summary-card {
flex: 1;
text-align: center;
padding: 10px;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
.summary-card .number {
font-size: 18pt;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.summary-card .label {
font-size: 8pt;
color: #64748B;
text-transform: uppercase;
margin-top: 2px;
}
/* Page break */
.page-break { page-break-before: always; }
</style>
</head>
<body>
<!-- Header -->
<div class="report-header">
<div class="logo-area">
{% if company.logo_base64 %}
<img src="{{ company.logo_base64 }}" alt="Logo">
{% endif %}
<span class="company-name">{{ company.company_name }}</span>
</div>
<div class="report-title">
{% block report_title %}Report{% endblock %}
</div>
</div>
<!-- Content -->
{% block content %}{% endblock %}
<!-- Footer -->
<div class="report-footer">
Generated {{ generated_at.strftime('%Y-%m-%d %H:%M') }} — Powered by TieMeasureFlow
</div>
</body>
</html>
@@ -0,0 +1,75 @@
{% extends "base_report.html" %}
{% block report_title %}Measurement Report{% endblock %}
{% block content %}
<!-- Recipe info + filters -->
<div class="info-box">
<span class="label">Recipe:</span> {{ recipe.code }} — {{ recipe.name }}
{% if filters_desc %}
<br>
<span class="label">Filters:</span> {{ filters_desc | join(' | ') }}
{% endif %}
</div>
<!-- Summary -->
<div class="section">
<div class="section-title">Summary</div>
<div class="summary-grid">
<div class="summary-card">
<div class="number mono">{{ summary.total }}</div>
<div class="label">Total</div>
</div>
<div class="summary-card pass-bg">
<div class="number mono pass">{{ summary.pass_count }}</div>
<div class="label">Pass ({{ summary.pass_rate }}%)</div>
</div>
<div class="summary-card warning-bg">
<div class="number mono warning">{{ summary.warning_count }}</div>
<div class="label">Warning ({{ summary.warning_rate }}%)</div>
</div>
<div class="summary-card fail-bg">
<div class="number mono fail">{{ summary.fail_count }}</div>
<div class="label">Fail ({{ summary.fail_rate }}%)</div>
</div>
</div>
</div>
<!-- Measurements Table -->
<div class="section">
<div class="section-title">Measurements ({{ n_measurements }})</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Measurement Point</th>
<th>Value</th>
<th>Unit</th>
<th>Result</th>
<th>Date</th>
<th>Operator</th>
<th>Lot</th>
<th>Serial</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr class="{% if row.pass_fail == 'fail' %}fail-bg{% elif row.pass_fail == 'warning' %}warning-bg{% endif %}">
<td class="mono">{{ row.num }}</td>
<td>{{ row.subtask }}</td>
<td class="mono">{{ row.value }}</td>
<td>{{ row.unit }}</td>
<td>
<span class="{{ row.pass_fail }}">{{ row.pass_fail | upper }}</span>
</td>
<td class="mono">{{ row.measured_at }}</td>
<td>{{ row.operator }}</td>
<td>{{ row.lot_number }}</td>
<td>{{ row.serial_number }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends "base_report.html" %}
{% block report_title %}SPC Report{% endblock %}
{% block content %}
<!-- Recipe info + filters -->
<div class="info-box">
<span class="label">Recipe:</span> {{ recipe.code }} — {{ recipe.name }}
{% if subtask %}
&nbsp;|&nbsp; <span class="label">Measurement Point:</span> #{{ subtask.marker_number }} — {{ subtask.description }}
{% endif %}
{% if filters_desc %}
<br>
<span class="label">Filters:</span> {{ filters_desc | join(' | ') }}
{% endif %}
</div>
<!-- Summary -->
<div class="section">
<div class="section-title">Summary</div>
<div class="summary-grid">
<div class="summary-card">
<div class="number mono">{{ summary.total }}</div>
<div class="label">Total</div>
</div>
<div class="summary-card pass-bg">
<div class="number mono pass">{{ summary.pass_rate }}%</div>
<div class="label">Pass ({{ summary.pass_count }})</div>
</div>
<div class="summary-card warning-bg">
<div class="number mono warning">{{ summary.warning_rate }}%</div>
<div class="label">Warning ({{ summary.warning_count }})</div>
</div>
<div class="summary-card fail-bg">
<div class="number mono fail">{{ summary.fail_rate }}%</div>
<div class="label">Fail ({{ summary.fail_count }})</div>
</div>
</div>
</div>
<!-- Capability Indices -->
{% if capability and capability.cp is not none %}
<div class="section">
<div class="section-title">Capability Indices</div>
<table>
<thead>
<tr>
<th>Index</th>
<th>Value</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
{% for idx_name, idx_val in [('Cp', capability.cp), ('Cpk', capability.cpk), ('Pp', capability.pp), ('Ppk', capability.ppk)] %}
<tr>
<td style="font-weight: 600;">{{ idx_name }}</td>
<td class="mono">{{ idx_val if idx_val is not none else '—' }}</td>
<td>
{% if idx_val is not none %}
{% if idx_val >= 1.33 %}
<span class="cap-good">Capable</span>
{% elif idx_val >= 1.0 %}
<span class="cap-marginal">Marginal</span>
{% else %}
<span class="cap-poor">Not Capable</span>
{% endif %}
{% else %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Statistics -->
<div class="info-box" style="margin-top: 8px;">
<span class="label">n:</span> <span class="mono">{{ capability.n }}</span>
&nbsp;|&nbsp; <span class="label">Mean:</span> <span class="mono">{{ capability.mean }}</span>
&nbsp;|&nbsp; <span class="label">Std Dev:</span> <span class="mono">{{ capability.std_dev }}</span>
{% if subtask %}
<br>
<span class="label">Tolerances:</span>
{% if subtask.nominal is not none %}Nom: <span class="mono">{{ subtask.nominal }}</span> {% endif %}
{% if subtask.utl is not none %}UTL: <span class="mono">{{ subtask.utl }}</span> {% endif %}
{% if subtask.ltl is not none %}LTL: <span class="mono">{{ subtask.ltl }}</span> {% endif %}
{% if subtask.uwl is not none %}UWL: <span class="mono">{{ subtask.uwl }}</span> {% endif %}
{% if subtask.lwl is not none %}LWL: <span class="mono">{{ subtask.lwl }}</span> {% endif %}
{% endif %}
</div>
</div>
{% endif %}
<!-- Control Chart -->
{% if control_chart_svg %}
<div class="section">
<div class="section-title">Control Chart</div>
<div class="chart-container">
{{ control_chart_svg | safe }}
</div>
</div>
{% endif %}
<!-- Histogram -->
{% if histogram_svg %}
<div class="section">
<div class="section-title">Histogram</div>
<div class="chart-container">
{{ histogram_svg | safe }}
</div>
</div>
{% endif %}
{% endblock %}