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."""
|
"""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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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