Files
pesco-ncr/backend/app/templates/ncr_pdf.html
ang3l12 c1cd1bc9df Add API Q1 CAPA: root cause, corrective action gate, effectiveness verification
Implements tester feedback against API Q1 §5.9.1.2 / §6.4.2:

- Root Cause field + 6M Root Cause Category lookup (Man/Machine/Method/
  Material/Measurement/Environment), separate from Deviation Detail/Category
- "Corrective Action Required?" Yes/No gate on every NCR with a required
  justification
- Corrective action plan with owner + due date; owner is notified by email
- Effectiveness verification (result, notes, server-stamped verifier/date)
  required before an NCR can close when corrective action is required —
  costing returns 409 listing the missing pieces
- Recurring-issue flag with bidirectional NCR-to-NCR links; prior NCRs show
  a warning when later NCRs reference them
- Dashboard metrics: % root cause completed, % CAPA verified effective,
  avg CAPA close time, overdue CAPA count, NCRs by root cause category
- CAPA section in the NCR detail UI, printable PDF, CSV export, and the
  vw_ncr_full Power BI view; admin list manager for root cause categories
- Migrations 0003 (schema + seeded 6M lookup) and 0004 (view refresh);
  demo seed data exercises every metric

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:49:08 -06:00

260 lines
9.7 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>Corrective &amp; Preventive Action (API Q1)</h2>
<table class="fields">
<tr>
<td class="lbl">Root Cause Category</td>
<td>{{ ncr.root_cause_category.name if ncr.root_cause_category else "—" }}</td>
<td class="lbl">Recurring Issue</td>
<td>
{% if ncr.is_recurring %}Yes{% if related_ncr_numbers %} —
{{ related_ncr_numbers | join(", ") }}{% endif %}
{% else %}No{% endif %}
</td>
</tr>
<tr>
<td class="lbl">Corrective Action Required</td>
<td>
{% if ncr.corrective_action_required is none %}<span class="pending">Not answered</span>
{% elif ncr.corrective_action_required %}Yes{% else %}No{% endif %}
</td>
<td class="lbl">Justification</td>
<td>{{ ncr.corrective_action_justification or "—" }}</td>
</tr>
<tr>
<td class="lbl">Action Owner</td>
<td>{{ ncr.corrective_action_owner.display_name if ncr.corrective_action_owner else "—" }}</td>
<td class="lbl">Due Date</td>
<td>{{ ncr.corrective_action_due_date.strftime("%Y-%m-%d") if ncr.corrective_action_due_date else "—" }}</td>
</tr>
<tr>
<td class="lbl">Effectiveness</td>
<td>
{% if ncr.effectiveness_result == "effective" %}Verified Effective
{% elif ncr.effectiveness_result == "not_effective" %}Not Effective
{% else %}<span class="pending">Pending verification</span>{% endif %}
</td>
<td class="lbl">Verified By / At</td>
<td>
{% if ncr.effectiveness_verified_by %}
{{ ncr.effectiveness_verified_by.display_name }} —
{{ ncr.effectiveness_verified_at.strftime("%Y-%m-%d %H:%M") }} UTC
{% else %}—{% endif %}
</td>
</tr>
</table>
{% if ncr.root_cause %}
<div class="notes"><strong>Root Cause:</strong> {{ ncr.root_cause }}</div>
{% else %}
<div class="notes pending">No root cause recorded.</div>
{% endif %}
{% if ncr.corrective_action_plan %}
<div class="notes"><strong>Corrective Action Plan:</strong> {{ ncr.corrective_action_plan }}</div>
{% endif %}
{% if ncr.effectiveness_notes %}
<div class="notes"><strong>Verification Notes:</strong> {{ ncr.effectiveness_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>