- 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
335 lines
14 KiB
Python
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}"
|