ixg_platform/apps/compliance/services.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

372 lines
14 KiB
Python

from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from apps.batches.models import ProcessingBatch
from apps.documents.models import CertificateOfAnalysis, Document
from apps.notifications.models import Notification
from apps.qc.models import QCResult
DEFAULT_PRODUCT = "Unrefined Shea Butter"
# Legacy fallback thresholds used only when no approved product specification exists.
FALLBACK_LIMITS = {
"ffa": {"max": Decimal("3.00")},
"moisture": {"max": Decimal("0.10")},
"peroxide": {"max": Decimal("10.00")},
}
# Sentinel: default behaviour looks up the current approved spec; an explicit
# None forces legacy fallback limits (used by tests and edge cases).
_USE_CURRENT = object()
def evaluate_qc_data(values, spec=_USE_CURRENT):
"""Evaluate a QC result against a specification (or legacy fallback limits).
Returns (overall_pass, result_status, parameter_details, spec_version).
"""
from apps.compliance.models import ProductSpecification, SpecParameter
values = dict(values)
if "visual" not in values and "visual_grade" in values:
values["visual"] = values["visual_grade"]
if spec is _USE_CURRENT:
spec = ProductSpecification.current(DEFAULT_PRODUCT)
spec_version = spec.version if spec else ""
details = {}
outside = False
missing = False
if spec:
for param in spec.parameters.all():
value = values.get(param.parameter)
status, detail = param.check_value(value)
details[param.parameter] = {"status": status, "detail": detail, "unit": param.unit}
if status == "outside":
outside = True
elif status == "missing":
missing = True
else:
for key, limits in FALLBACK_LIMITS.items():
value = values.get(key)
try:
numeric = float(value)
except (TypeError, ValueError):
missing = True
details[key] = {"status": "missing", "detail": "Value missing", "unit": ""}
continue
ok = numeric <= float(limits["max"])
details[key] = {
"status": "within" if ok else "outside",
"detail": "Within specification" if ok else "Above maximum",
"unit": "",
}
if not ok:
outside = True
visual = values.get("visual")
if visual and visual != "Fail":
details["visual"] = {"status": "within", "detail": "Within specification", "unit": ""}
else:
outside = True
details["visual"] = {"status": "outside", "detail": "Visual grade failed", "unit": ""}
if outside:
result_status = QCResult.ResultStatus.OUTSIDE_SPEC
elif missing:
result_status = QCResult.ResultStatus.MISSING
else:
result_status = QCResult.ResultStatus.WITHIN_SPEC
overall_pass = not outside and not missing
return overall_pass, result_status, details, spec_version
def find_source_lab_document(batch):
"""Return the most recent uploaded independent laboratory report for a batch."""
return Document.objects.filter(batch=batch, doc_type=Document.DocType.LAB_REPORT).order_by("-uploaded_at").first()
def create_coa_for_qc(qc, approved_by):
"""Generate the IXG Quality Summary PDF and create the (pending) CoA record.
Only called after a separate quality approval of a passing QC result (four-eyes).
"""
from apps.compliance.coa_pdf import generate_coa_pdf, write_coa_file
if not qc.overall_pass:
raise ValueError("CoA can only be generated for a passing QC result")
if CertificateOfAnalysis.objects.filter(qc_result=qc).exists():
return CertificateOfAnalysis.objects.get(qc_result=qc)
batch = qc.batch
pdf_bytes, digest = generate_coa_pdf(qc, batch, spec_version=qc.spec_version)
file_url, file_name = write_coa_file(qc, batch, pdf_bytes)
source = find_source_lab_document(batch)
return CertificateOfAnalysis.objects.create(
batch=batch,
qc_result=qc,
file_url=file_url,
file_name=file_name,
issuer="IXG Ltd",
issue_date=timezone.localdate(),
uploaded_by=approved_by,
status=CertificateOfAnalysis.Status.PENDING_APPROVAL,
version=1,
sha256_hash=digest,
spec_version=qc.spec_version,
source_document=source,
quality_approved_by=approved_by,
quality_approved_at=timezone.now(),
)
def publish_coa(coa, released_by):
"""Commercial release approval: mark published, expose to buyers, notify buyers."""
if coa.status == CertificateOfAnalysis.Status.PUBLISHED:
return coa
with transaction.atomic():
coa.status = CertificateOfAnalysis.Status.PUBLISHED
coa.released_by = released_by
coa.released_at = timezone.now()
coa.published_at = timezone.now()
coa.save(update_fields=[
"status", "released_by", "released_at", "published_at",
])
Document.objects.create(
batch=coa.batch,
doc_type=Document.DocType.COA,
file_url=coa.file_url,
file_name=coa.file_name,
visibility=Document.Visibility.BUYER,
uploaded_by=released_by,
)
for buyer in _buyer_users():
Notification.objects.create(
user=buyer,
type="coa_published",
title=f"Certificate of Analysis published: {coa.batch.batch_reference}",
message=f"A Certificate of Analysis is now available for batch {coa.batch.batch_reference}.",
)
return coa
def _buyer_users():
from apps.users.models import User
return User.objects.filter(role=User.Roles.BUYER, is_active=True)
# ---------------------------------------------------------------------------
# EUDR evidence workflow
# ---------------------------------------------------------------------------
def ensure_due_diligence_case(batch):
from apps.compliance.models import DueDiligenceCase
case, _ = DueDiligenceCase.objects.get_or_create(batch=batch)
return case
def _lineage(batch):
return list(batch.intake_lineage.select_related("harvest_intake", "harvest_intake__origin_zone", "harvest_intake__collector_group").all())
def run_completeness_check(case):
"""Evaluate evidence completeness for a due diligence case.
Outputs a JSON results map and the resulting case status. The system never
labels a case 'EUDR compliant' - it only reports evidence state.
"""
from apps.compliance.models import DueDiligenceCase
batch = case.batch
links = _lineage(batch)
results = {}
if links:
zone_statuses = []
for link in links:
intake = link.harvest_intake
zone = intake.origin_zone
if zone is None:
zone_statuses.append("no_zone")
elif zone.verification_status == "verified":
zone_statuses.append("verified")
elif zone.verification_status == "rejected":
zone_statuses.append("rejected")
else:
zone_statuses.append("unverified")
results["geolocation"] = {
"pass": all(s == "verified" for s in zone_statuses),
"detail": "All intakes trace to a verified origin zone" if all(s == "verified" for s in zone_statuses)
else f"Zone states: {', '.join(zone_statuses)}",
}
else:
results["geolocation"] = {"pass": False, "detail": "No intake lineage recorded for batch"}
results["lineage"] = {
"pass": len(links) > 0,
"detail": f"{len(links)} harvest intake(s) linked to batch" if links else "Batch has no intake lineage",
}
complete_intakes = all(
link.harvest_intake.intake_date and link.harvest_intake.weight_kg is not None
for link in links
) if links else False
results["intake_data"] = {
"pass": complete_intakes,
"detail": "Intake date and weight present" if complete_intakes else "Intake date/weight missing",
}
required_types = {"geolocation", "harvest_document", "supplier_identity"}
evidence_types = {e.evidence_type for e in case.evidence.all()}
evidence_pass = required_types.issubset(evidence_types)
results["evidence"] = {
"pass": evidence_pass,
"detail": "Required evidence present" if evidence_pass
else f"Missing evidence types: {', '.join(sorted(required_types - evidence_types))}",
}
risk_records = case.risk_assessments.count()
results["risk_assessment"] = {
"pass": risk_records > 0,
"detail": f"{risk_records} risk assessment(s) recorded" if risk_records else "No risk assessment recorded",
}
case.completeness_results = results
if case.status in (DueDiligenceCase.Status.APPROVED, DueDiligenceCase.Status.REJECTED):
case.save(update_fields=["completeness_results", "updated_at"])
return case
all_pass = all(r["pass"] for r in results.values())
if not all_pass:
case.status = DueDiligenceCase.Status.INCOMPLETE
else:
if case.risk_level == DueDiligenceCase.RiskLevel.HIGH or case.risk_assessments.filter(risk_level="high").exists():
case.status = DueDiligenceCase.Status.MITIGATION_REQUIRED
else:
case.status = DueDiligenceCase.Status.COMPLETE_FOR_REVIEW
case.save(update_fields=["status", "completeness_results", "updated_at"])
return case
def evaluate_risk(case, user=None):
"""Rule-based risk assessment. Transparency over inference: each finding is
grounded in a concrete, reviewable rule. No AI-derived legal conclusions."""
from apps.compliance.models import RiskAssessment
findings = []
level = "standard"
links = _lineage(case.batch)
if not links:
level = "high"
findings.append("No intake lineage recorded - risk of untraceable origin")
else:
unverified = any(
l.harvest_intake.origin_zone is None or l.harvest_intake.origin_zone.verification_status != "verified"
for l in links
)
if unverified:
level = "high"
findings.append("One or more origin zones unverified or missing")
expired_or_missing = not case.evidence.exists()
if expired_or_missing:
findings.append("Required EUDR evidence absent")
# Regulatory context (transparent, not a legal conclusion).
findings.append("Shea butter is not listed in EUDR Annex I; this tracker provides due-diligence "
"evidence and buyer assurance, not an EU-mandated filing")
risk = RiskAssessment.objects.create(
case=case,
risk_level=level,
basis="; ".join(findings),
findings="; ".join(findings),
assessed_by=user,
assessed_at=timezone.now(),
)
case.risk_level = level
case.assessed_by = user
case.assessed_at = timezone.now()
if level == "high":
case.status = "mitigation_required"
case.save(update_fields=["risk_level", "assessed_by", "assessed_at", "status", "updated_at"])
return risk
def approve_case(case, user):
"""Named compliance approval of a complete case."""
if case.status not in ("complete_for_review", "mitigation_required", "risk_identified"):
raise ValueError("Case is not ready for approval")
open_mitigations = case.mitigation_actions.exclude(status="verified").exists()
if case.risk_level == "high" and open_mitigations:
raise ValueError("High-risk case has unverified mitigation actions")
case.status = "approved"
case.approved_by = user
case.approved_at = timezone.now()
case.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
return case
def reject_case(case, user, reason):
if not reason:
raise ValueError("Rejection reason is required")
case.status = "rejected"
case.approved_by = user
case.approved_at = timezone.now()
case.rejected_reason = reason
case.save(update_fields=["status", "approved_by", "approved_at", "rejected_reason", "updated_at"])
return case
def record_submission(case, user, dds_reference, submission_date):
from apps.compliance.models import DueDiligenceStatementReference
return DueDiligenceStatementReference.objects.create(
case=case,
dds_reference=dds_reference,
submission_date=submission_date,
submitted_by=user,
)
def evidence_package(case):
"""Assemble the exportable evidence package summary."""
batch = case.batch
intakes = [l.harvest_intake for l in _lineage(batch)]
return {
"batch_reference": batch.batch_reference,
"product": DEFAULT_PRODUCT,
"weight_kg": float(batch.weight_kg) if batch.weight_kg is not None else None,
"origin_zones": [
{"zone_code": i.origin_zone.zone_code, "country": i.origin_zone.country,
"verification_status": i.origin_zone.verification_status}
for i in intakes if i.origin_zone
],
"collector_groups": [
{"name": i.collector_group.name, "contact_reference": i.collector_group.contact_reference}
for i in intakes if i.collector_group
],
"intake_count": len(intakes),
"risk_level": case.risk_level,
"status": case.status,
"approved_by": case.approved_by.full_name if case.approved_by else None,
"approved_at": case.approved_at.isoformat() if case.approved_at else None,
"dds_references": [
{"dds_reference": r.dds_reference, "submission_date": r.submission_date.isoformat() if r.submission_date else None}
for r in case.statement_references.all()
],
}