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

335 lines
14 KiB
Python

import uuid
from django.conf import settings
from django.db import models
class ProductSpecification(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
APPROVED = "approved", "Approved"
SUPERSEDED = "superseded", "Superseded"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
product = models.CharField(max_length=100)
version = models.CharField(max_length=20)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
effective_from = models.DateField(null=True, blank=True)
effective_to = models.DateField(null=True, blank=True)
is_current = models.BooleanField(default=False)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
related_name="specs_approved",
)
approved_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "product_specifications"
unique_together = [("product", "version")]
ordering = ["product", "-version"]
def __str__(self):
return f"{self.product} v{self.version} ({self.status})"
@classmethod
def current(cls, product):
return cls.objects.filter(product=product, status=cls.Status.APPROVED, is_current=True).first()
class SpecParameter(models.Model):
class Parameter(models.TextChoices):
FFA = "ffa", "Free Fatty Acid"
MOISTURE = "moisture", "Moisture"
PEROXIDE = "peroxide", "Peroxide Value"
VISUAL = "visual", "Visual Grade"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
spec = models.ForeignKey(ProductSpecification, on_delete=models.CASCADE, related_name="parameters")
parameter = models.CharField(max_length=20, choices=Parameter.choices)
unit = models.CharField(max_length=50, blank=True)
min_value = models.DecimalField(max_digits=8, decimal_places=3, null=True, blank=True)
max_value = models.DecimalField(max_digits=8, decimal_places=3, null=True, blank=True)
allowed_values = models.CharField(max_length=255, blank=True)
required = models.BooleanField(default=True)
sort_order = models.IntegerField(default=0)
class Meta:
db_table = "spec_parameters"
unique_together = [("spec", "parameter")]
ordering = ["sort_order"]
def __str__(self):
return f"{self.get_parameter_display()} ({self.unit})"
def check_value(self, value):
if value is None or value == "":
if self.required:
return ("missing", "Value required")
return ("within", "Optional value absent")
if self.parameter == self.Parameter.VISUAL:
allowed = [v.strip() for v in self.allowed_values.split(",") if v.strip()]
if allowed and str(value) not in allowed:
return ("outside", f"Visual grade '{value}' not in allowed values: {', '.join(allowed)}")
return ("within", "Within specification")
try:
numeric = float(value)
except (TypeError, ValueError):
return ("missing", "Non-numeric value")
if self.min_value is not None and numeric < float(self.min_value):
return ("outside", f"{value} below minimum {self.min_value} {self.unit}")
if self.max_value is not None and numeric > float(self.max_value):
return ("outside", f"{value} above maximum {self.max_value} {self.unit}")
return ("within", "Within specification")
class Facility(models.Model):
class FacilityType(models.TextChoices):
PRODUCTION = "production", "Production"
PROCESSING = "processing", "Processing"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
country = models.CharField(max_length=100)
region = models.CharField(max_length=100, blank=True)
address = models.TextField(blank=True)
facility_type = models.CharField(max_length=20, choices=FacilityType.choices, default=FacilityType.PRODUCTION)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "facilities"
def __str__(self):
return self.name
class OriginZone(models.Model):
class VerificationStatus(models.TextChoices):
UNVERIFIED = "unverified", "Unverified"
VERIFIED = "verified", "Verified"
REJECTED = "rejected", "Rejected"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="origin_zones")
zone_code = models.CharField(max_length=100, unique=True)
country = models.CharField(max_length=100)
region = models.CharField(max_length=100, blank=True)
geometry = models.JSONField(default=dict, blank=True)
coordinate_source = models.CharField(max_length=255, blank=True)
coordinates_collected_by = models.CharField(max_length=255, blank=True)
coordinates_collected_at = models.DateField(null=True, blank=True)
evidence_file = models.CharField(max_length=255, blank=True)
verification_status = models.CharField(
max_length=20, choices=VerificationStatus.choices, default=VerificationStatus.UNVERIFIED
)
verified_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="zones_verified"
)
verified_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "origin_zones"
def __str__(self):
return f"{self.zone_code} ({self.country})"
class CollectorGroup(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
contact_reference = models.CharField(max_length=255, blank=True)
origin_zone = models.ForeignKey(
OriginZone, on_delete=models.SET_NULL, null=True, blank=True, related_name="collector_groups"
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "collector_groups"
def __str__(self):
return self.name
class HarvestIntake(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
RECEIVED = "received", "Received"
VERIFIED = "verified", "Verified"
REJECTED = "rejected", "Rejected"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
intake_reference = models.CharField(max_length=100, unique=True)
collector_group = models.ForeignKey(
CollectorGroup, on_delete=models.SET_NULL, null=True, blank=True, related_name="intakes"
)
origin_zone = models.ForeignKey(
OriginZone, on_delete=models.SET_NULL, null=True, blank=True, related_name="intakes"
)
intake_date = models.DateField(null=True, blank=True)
weight_kg = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
grade = models.CharField(max_length=5, blank=True)
receiving_officer = models.CharField(max_length=255, blank=True)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
evidence_file = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "harvest_intakes"
def __str__(self):
return self.intake_reference
class IntakeBatchLineage(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="intake_lineage")
harvest_intake = models.ForeignKey(HarvestIntake, on_delete=models.CASCADE, related_name="batch_lineage")
weight_allocated_kg = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "intake_batch_lineage"
unique_together = [("batch", "harvest_intake")]
def __str__(self):
return f"{self.batch.batch_reference} <- {self.harvest_intake.intake_reference}"
class DueDiligenceCase(models.Model):
class Status(models.TextChoices):
INCOMPLETE = "incomplete", "Incomplete"
COMPLETE_FOR_REVIEW = "complete_for_review", "Complete for Review"
RISK_IDENTIFIED = "risk_identified", "Risk Identified"
MITIGATION_REQUIRED = "mitigation_required", "Mitigation Required"
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
class RiskLevel(models.TextChoices):
LOW = "low", "Low"
STANDARD = "standard", "Standard"
HIGH = "high", "High"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
batch = models.OneToOneField("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="due_diligence_case")
status = models.CharField(max_length=30, choices=Status.choices, default=Status.INCOMPLETE)
risk_level = models.CharField(max_length=20, choices=RiskLevel.choices, default=RiskLevel.STANDARD)
completeness_results = models.JSONField(default=dict, blank=True)
assessed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="eudr_cases_assessed"
)
assessed_at = models.DateTimeField(null=True, blank=True)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="eudr_cases_approved"
)
approved_at = models.DateTimeField(null=True, blank=True)
rejected_reason = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "due_diligence_cases"
def __str__(self):
return f"EUDR case - {self.batch.batch_reference} ({self.status})"
class EUDREvidence(models.Model):
class EvidenceType(models.TextChoices):
GEOLOCATION = "geolocation", "Geolocation"
HARVEST_DOCUMENT = "harvest_document", "Harvest Document"
SUPPLIER_IDENTITY = "supplier_identity", "Supplier Identity"
LEGALITY = "legality", "Legality"
OTHER = "other", "Other"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="evidence")
evidence_type = models.CharField(max_length=30, choices=EvidenceType.choices, default=EvidenceType.OTHER)
file = models.CharField(max_length=255)
source = models.CharField(max_length=255, blank=True)
collected_at = models.DateField(null=True, blank=True)
verified_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="eudr_evidence_verified"
)
verified_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "eudr_evidence"
def __str__(self):
return f"{self.get_evidence_type_display()} - {self.case.batch.batch_reference}"
class RiskAssessment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="risk_assessments")
risk_level = models.CharField(max_length=20, choices=DueDiligenceCase.RiskLevel.choices, default=DueDiligenceCase.RiskLevel.STANDARD)
basis = models.TextField(blank=True)
findings = models.TextField(blank=True)
assessed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="risk_assessments"
)
assessed_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "risk_assessments"
ordering = ["-assessed_at"]
def __str__(self):
return f"Risk {self.risk_level} - {self.case.batch.batch_reference}"
class MitigationAction(models.Model):
class Status(models.TextChoices):
OPEN = "open", "Open"
IN_PROGRESS = "in_progress", "In Progress"
COMPLETED = "completed", "Completed"
VERIFIED = "verified", "Verified"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="mitigation_actions")
action = models.TextField()
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN)
owner = models.CharField(max_length=255, blank=True)
due_date = models.DateField(null=True, blank=True)
evidence_file = models.CharField(max_length=255, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "mitigation_actions"
def __str__(self):
return f"Mitigation - {self.case.batch.batch_reference} ({self.status})"
class DueDiligenceStatementReference(models.Model):
class ReconciliationStatus(models.TextChoices):
PENDING = "pending", "Pending"
RECONCILED = "reconciled", "Reconciled"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="statement_references")
dds_reference = models.CharField(max_length=255, blank=True)
submission_date = models.DateField(null=True, blank=True)
submitted_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="dds_submissions"
)
reconciliation_status = models.CharField(
max_length=20, choices=ReconciliationStatus.choices, default=ReconciliationStatus.PENDING
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "due_diligence_statement_references"
def __str__(self):
return f"DDS {self.dds_reference or '-'} - {self.case.batch.batch_reference}"