Complete Non-Conformance Report system replacing the PowerApps/SharePoint prototype: FastAPI + SQLAlchemy 2 (async) + Alembic + MySQL 8 backend, React 18 + Vite + TypeScript + MUI frontend, Entra ID auth (MSAL / JWKS, group-gated), Microsoft Graph delegated Mail.Send notifications (OBO), six-stage workflow state machine with server-side enforcement, atomic NCR-YYYY-NNNN numbering, attachments with camera capture, immutable field-level audit trail, admin reopen, reports + CSV export, WeasyPrint PDF traveler, Power BI reporting views + read-only DB user, documented VISUAL ERP job-lookup stub, pytest suite (26 tests), docker-compose deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
205 lines
7.6 KiB
HTML
205 lines
7.6 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
@page {
|
|
size: letter;
|
|
margin: 18mm 14mm 20mm 14mm;
|
|
@bottom-left { content: "{{ ncr.ncr_number }} — Non-Conformance Report"; font-size: 8pt; color: #777; }
|
|
@bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 8pt; color: #777; }
|
|
}
|
|
body { font-family: "DejaVu Sans", sans-serif; font-size: 9.5pt; color: #1a1a1a; }
|
|
h1 { font-size: 17pt; margin: 0; }
|
|
h2 {
|
|
font-size: 10.5pt; text-transform: uppercase; letter-spacing: 0.06em;
|
|
background: #eef2f7; border-left: 4px solid #1a5fb4; padding: 4px 8px;
|
|
margin: 16px 0 6px;
|
|
}
|
|
.header { display: flex; justify-content: space-between; align-items: flex-start;
|
|
border-bottom: 3px solid #1a5fb4; padding-bottom: 8px; }
|
|
.brand { font-size: 13pt; font-weight: bold; color: #1a5fb4; }
|
|
.doc-meta { text-align: right; font-size: 9pt; color: #444; }
|
|
.ncr-number { font-size: 15pt; font-weight: bold; }
|
|
.stage-chip { display: inline-block; background: #1a5fb4; color: #fff;
|
|
padding: 2px 10px; border-radius: 10px; font-size: 9pt; }
|
|
table.fields { width: 100%; border-collapse: collapse; margin: 4px 0; }
|
|
table.fields td { border: 1px solid #ccd4de; padding: 5px 7px; vertical-align: top; }
|
|
table.fields td.lbl { width: 24%; background: #f6f8fa; color: #555; font-size: 8.5pt;
|
|
text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.notes { border: 1px solid #ccd4de; padding: 7px; min-height: 30px; }
|
|
.pending { color: #999; font-style: italic; }
|
|
table.history { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
|
|
table.history th { background: #f6f8fa; border: 1px solid #ccd4de; padding: 4px 6px;
|
|
text-align: left; }
|
|
table.history td { border: 1px solid #ccd4de; padding: 4px 6px; }
|
|
.thumb-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.thumb { width: 30%; border: 1px solid #ccd4de; padding: 4px; }
|
|
.thumb img { width: 100%; max-height: 150px; object-fit: contain; }
|
|
.thumb .cap { font-size: 7.5pt; color: #666; margin-top: 2px; }
|
|
.costs td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
.costs tr.total td { font-weight: bold; background: #eef2f7; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<div>
|
|
<div class="brand">PESCO</div>
|
|
<h1>Non-Conformance Report</h1>
|
|
</div>
|
|
<div class="doc-meta">
|
|
<div class="ncr-number">{{ ncr.ncr_number }}</div>
|
|
<div>Stage: <span class="stage-chip">{{ stage_label }}</span></div>
|
|
<div>Generated {{ generated_at.strftime("%Y-%m-%d %H:%M") }} UTC</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Request</h2>
|
|
<table class="fields">
|
|
<tr>
|
|
<td class="lbl">NCR Number</td><td>{{ ncr.ncr_number }}</td>
|
|
<td class="lbl">Date</td><td>{{ ncr.created_at.strftime("%Y-%m-%d") }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="lbl">Job Number</td><td>{{ ncr.job_number }}</td>
|
|
<td class="lbl">Department</td><td>{{ ncr.department.name }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="lbl">Deviation Category</td><td>{{ ncr.deviation_category.name }}</td>
|
|
<td class="lbl">Requester</td><td>{{ ncr.requester.display_name }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="lbl">Disposition Authority</td><td>{{ ncr.disposition_authority.display_name }}</td>
|
|
<td class="lbl">Work Order</td><td>{{ ncr.work_order or "—" }}</td>
|
|
</tr>
|
|
{% if ncr.job_info %}
|
|
<tr>
|
|
<td class="lbl">Part (ERP)</td>
|
|
<td>{{ ncr.job_info.part_id or "—" }} {{ ncr.job_info.part_description or "" }}</td>
|
|
<td class="lbl">Customer (ERP)</td><td>{{ ncr.job_info.customer_name or "—" }}</td>
|
|
</tr>
|
|
{% endif %}
|
|
</table>
|
|
<div class="notes">{{ ncr.deviation_detail }}</div>
|
|
|
|
<h2>Disposition</h2>
|
|
<table class="fields">
|
|
<tr>
|
|
<td class="lbl">QC Authority</td><td>{{ ncr.qc_authority or "—" }}</td>
|
|
<td class="lbl">Secondary Review</td>
|
|
<td>
|
|
{% if ncr.secondary_review_needed is none %}—
|
|
{% elif ncr.secondary_review_needed %}Yes —
|
|
{{ ncr.secondary_authorities | map(attribute="display_name") | join(", ") or "unassigned" }}
|
|
{% else %}No{% endif %}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
{% if ncr.disposition_notes %}
|
|
<div class="notes">{{ ncr.disposition_notes | safe }}</div>
|
|
{% else %}
|
|
<div class="notes pending">No disposition notes recorded.</div>
|
|
{% endif %}
|
|
|
|
<h2>Operations</h2>
|
|
<table class="fields">
|
|
<tr>
|
|
<td class="lbl">Operations Complete</td>
|
|
<td>{% if ncr.operations_complete %}Yes{% else %}<span class="pending">Pending</span>{% endif %}</td>
|
|
<td class="lbl">Completed By / At</td>
|
|
<td>
|
|
{% if ncr.operations_completed_by %}
|
|
{{ ncr.operations_completed_by.display_name }} —
|
|
{{ ncr.operations_completed_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
|
{% else %}—{% endif %}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<h2>QC Inspection</h2>
|
|
<table class="fields">
|
|
<tr>
|
|
<td class="lbl">QC Approval</td>
|
|
<td>{{ ncr.qc_approval | capitalize if ncr.qc_approval else "—" }}</td>
|
|
<td class="lbl">QC Closed</td>
|
|
<td>
|
|
{% if ncr.qc_closed %}Yes — {{ ncr.qc_closed_by.display_name }},
|
|
{{ ncr.qc_closed_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
|
{% else %}<span class="pending">Pending</span>{% endif %}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
{% if ncr.inspection_notes %}
|
|
<div class="notes">{{ ncr.inspection_notes }}</div>
|
|
{% endif %}
|
|
|
|
<h2>Costing</h2>
|
|
<table class="fields costs">
|
|
<tr>
|
|
<td class="lbl">Labor</td>
|
|
<td class="num">{% if ncr.labor_cost is not none %}${{ "%.2f" | format(ncr.labor_cost) }}{% else %}—{% endif %}</td>
|
|
<td class="lbl">Material</td>
|
|
<td class="num">{% if ncr.material_cost is not none %}${{ "%.2f" | format(ncr.material_cost) }}{% else %}—{% endif %}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="lbl">Service</td>
|
|
<td class="num">{% if ncr.service_cost is not none %}${{ "%.2f" | format(ncr.service_cost) }}{% else %}—{% endif %}</td>
|
|
<td class="lbl">Other</td>
|
|
<td class="num">{% if ncr.other_cost is not none %}${{ "%.2f" | format(ncr.other_cost) }}{% else %}—{% endif %}</td>
|
|
</tr>
|
|
<tr class="total">
|
|
<td class="lbl">Total Cost of Nonconformance</td>
|
|
<td class="num" colspan="3">
|
|
{% if ncr.total_cost is not none %}${{ "%.2f" | format(ncr.total_cost) }}{% else %}—{% endif %}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
{% if ncr.closed_at %}
|
|
<p>Closed by {{ ncr.closed_by.display_name }} on {{ ncr.closed_at.strftime("%Y-%m-%d %H:%M") }} UTC.</p>
|
|
{% endif %}
|
|
|
|
{% if images or other_files %}
|
|
<h2>Attachments ({{ images | length + other_files | length }})</h2>
|
|
{% if images %}
|
|
<div class="thumb-grid">
|
|
{% for img in images %}
|
|
<div class="thumb">
|
|
<img src="{{ img.src }}" alt="{{ img.filename }}">
|
|
<div class="cap">{{ img.filename }} — {{ img.uploaded_by }},
|
|
{{ img.uploaded_at.strftime("%Y-%m-%d") }}</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
{% if other_files %}
|
|
<table class="history" style="margin-top:6px">
|
|
<tr><th>File</th><th>Uploaded By</th><th>Date</th><th>Size</th></tr>
|
|
{% for f in other_files %}
|
|
<tr>
|
|
<td>{{ f.filename }}</td><td>{{ f.uploaded_by }}</td>
|
|
<td>{{ f.uploaded_at.strftime("%Y-%m-%d %H:%M") }}</td><td>{{ f.size_kb }} KB</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
<h2>Workflow History</h2>
|
|
<table class="history">
|
|
<tr><th>Date (UTC)</th><th>Action</th><th>From</th><th>To</th><th>By</th><th>Note</th></tr>
|
|
{% for t in ncr.transitions %}
|
|
<tr>
|
|
<td>{{ t.acted_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
|
<td>{{ t.action.replace("_", " ") | title }}</td>
|
|
<td>{{ stage_labels[Stage(t.from_stage)] if t.from_stage else "—" }}</td>
|
|
<td>{{ stage_labels[Stage(t.to_stage)] }}</td>
|
|
<td>{{ t.acted_by.display_name }}</td>
|
|
<td>{{ t.note or "" }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
|
|
</body>
|
|
</html>
|