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() ], }