- 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
372 lines
14 KiB
Python
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()
|
|
],
|
|
}
|