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:
@@ -1,8 +1,11 @@
|
||||
"""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 blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
|
||||
statistics_bp = Blueprint("statistics", __name__)
|
||||
@@ -105,3 +108,71 @@ def api_subtasks():
|
||||
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")
|
||||
|
||||
@@ -61,8 +61,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<!-- Apply button + Download -->
|
||||
<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()"
|
||||
: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">
|
||||
@@ -233,6 +266,7 @@ function spcDashboard() {
|
||||
loading: false,
|
||||
hasData: false,
|
||||
errorMessage: '',
|
||||
downloadingReport: false,
|
||||
|
||||
init() {
|
||||
// Nothing to pre-load
|
||||
@@ -368,6 +402,43 @@ function spcDashboard() {
|
||||
if (value >= 1.0) return 'text-amber-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>
|
||||
|
||||
@@ -1038,3 +1038,13 @@ msgstr "Normal curve"
|
||||
|
||||
msgid "Indici non disponibili"
|
||||
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"
|
||||
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"
|
||||
|
||||
@@ -15,6 +15,7 @@ from routers.tasks import router as tasks_router
|
||||
from routers.measurements import router as measurements_router
|
||||
from routers.files import router as files_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
|
||||
|
||||
|
||||
@@ -60,6 +61,7 @@ app.include_router(measurements_router)
|
||||
app.include_router(files_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(statistics_router)
|
||||
app.include_router(reports_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -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}"'},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
| <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>
|
||||
| <span class="label">Mean:</span> <span class="mono">{{ capability.mean }}</span>
|
||||
| <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 %}
|
||||
Reference in New Issue
Block a user