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>
260 lines
9.7 KiB
HTML
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 & 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>
|