ixg_platform/apps/compliance/coa_pdf.py
ixgadmin 08096e168f P99C: compliance automation - CoA auto-generation and EUDR due diligence
- Versioned product specification table (Unrefined Shea Butter v1.0 seed) with
  spec-based QC evaluation replacing hard-coded thresholds
- Structured QC results with test methods and four-eyes verification
  (submitter cannot self-approve)
- reportlab IXG Quality Summary PDF auto-generated on quality approval,
  SHA-256 hashed; original independent lab PDF kept authoritative
- CertificateOfAnalysis version/status/hash/approval/supersede lifecycle;
  commercial release publishes to buyers and triggers notification
- EUDR evidence workflow: Facility, OriginZone, CollectorGroup, HarvestIntake,
  IntakeBatchLineage, DueDiligenceCase, EUDREvidence, RiskAssessment,
  MitigationAction, DueDiligenceStatementReference with rule-based
  completeness check and risk assessment (no AI legal conclusions)
- Ops portal: QC Approvals, CoA Reviews, EUDR Compliance screens
- Buyer portal: /portal/compliance page
- 22 automated tests covering four-eyes, CAPA, PDF+hash, publish/notify,
  EUDR blocks and buyer isolation
2026-08-05 13:58:39 +00:00

124 lines
5.4 KiB
Python

import hashlib
from datetime import date
from io import BytesIO
from django.utils import timezone
def generate_coa_pdf(qc, batch, spec_version="", issuer="IXG Ltd"):
"""Generate the IXG Quality Summary cover sheet PDF for an approved QC result.
This is a companion to (never a replacement for) the independent laboratory
certificate, which remains the authoritative source document.
Returns (pdf_bytes, sha256_hex).
"""
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"TitleX", parent=styles["Title"], fontSize=16, leading=20,
textColor=colors.HexColor("#1A3D2B"),
)
sub_style = ParagraphStyle(
"SubX", parent=styles["Normal"], fontSize=9, leading=12,
textColor=colors.HexColor("#4B5563"),
)
cell_style = ParagraphStyle("CellX", parent=styles["Normal"], fontSize=9, leading=11)
issued = date.today()
reference = f"{batch.batch_reference}-COA-V{1}"
rows = [
["Batch reference", batch.batch_reference],
["Product", "Unrefined Shea Butter"],
["Origin", batch.origin_facility],
["Grade", batch.grade or "-"],
["Weight", f"{batch.weight_kg} kg" if batch.weight_kg is not None else "-"],
["Harvest date", batch.harvest_date.isoformat() if batch.harvest_date else "-"],
["Specification", spec_version or "-"],
["Lab reference", qc.lab_reference or "-"],
["Tested by", qc.tested_by or "-"],
["Quality approved by", (qc.verified_by.full_name or qc.verified_by.email) if qc.verified_by else "-"],
["Quality approved on", qc.approved_at.isoformat() if qc.approved_at else "-"],
["Issued by", issuer],
["Issue date", issued.isoformat()],
]
body = [
["Parameter", "Result", "Unit", "Result status"],
["FFA", f"{qc.ffa_percentage}", "%", "Within specification" if float(qc.ffa_percentage) <= 3 else "Outside specification"],
["Moisture", f"{qc.moisture_percentage}", "%", "Within specification" if float(qc.moisture_percentage) <= 0.1 else "Outside specification"],
["Peroxide value", f"{qc.peroxide_value}", "meq O2/kg", "Within specification" if float(qc.peroxide_value) <= 10 else "Outside specification"],
["Visual grade", qc.visual_grade, "-", "Within specification" if qc.visual_grade != "Fail" else "Outside specification"],
]
pdf = BytesIO()
doc = SimpleDocTemplate(pdf, pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=18 * mm, bottomMargin=18 * mm)
elements = []
elements.append(Paragraph("IXG Ltd &mdash; Quality Summary", title_style))
elements.append(Spacer(1, 2 * mm))
elements.append(Paragraph(
"Companion document to the independent laboratory Certificate of Analysis. "
"The independent laboratory certificate remains the authoritative source document.",
sub_style,
))
elements.append(Spacer(1, 6 * mm))
header_table = Table([[Paragraph(f"Reference: {reference}", sub_style)]], colWidths=[120 * mm])
elements.append(header_table)
elements.append(Spacer(1, 4 * mm))
meta = Table([[Paragraph(c, cell_style) for c in row] for row in rows], colWidths=[55 * mm, 115 * mm])
meta.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#EAF3EF")),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#CCD3CF")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
elements.append(meta)
elements.append(Spacer(1, 6 * mm))
elements.append(Paragraph("Results vs Specification", styles["Heading3"]))
elements.append(Spacer(1, 2 * mm))
result_table = Table(body, colWidths=[55 * mm, 35 * mm, 30 * mm, 50 * mm], repeatRows=1)
result_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1A3D2B")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#CCD3CF")),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F7F6F2")]),
]))
elements.append(result_table)
elements.append(Spacer(1, 6 * mm))
elements.append(Paragraph(
"This summary is derived from the structured QC record on the IXG platform. "
"The independent laboratory certificate remains the authoritative source document.",
sub_style,
))
doc.build(elements)
data = pdf.getvalue()
digest = hashlib.sha256(data).hexdigest()
return data, digest
def write_coa_file(qc, batch, pdf_bytes):
"""Persist the generated PDF under media/ixg-documents/{batch_id}/ and return (file_url, file_name)."""
import os
import uuid as uuid_mod
from django.conf import settings
batch_dir = os.path.join(settings.MEDIA_ROOT, "ixg-documents", str(batch.id))
os.makedirs(batch_dir, exist_ok=True)
filename = f"coa-{uuid_mod.uuid4().hex[:8]}-{batch.batch_reference}.pdf"
filepath = os.path.join(batch_dir, filename)
with open(filepath, "wb") as f:
f.write(pdf_bytes)
return f"/media/ixg-documents/{batch.id}/{filename}", filename