- 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
124 lines
5.4 KiB
Python
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 — 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
|