Add batch create, edit and lineage screens to the ops portal
The batch form refuses to type over a QC status or move a failed batch, and the lineage formset refuses an allocation the intake cannot support. The origin-facility help text and the QC status card now describe what the form actually does, and buyer POSTs to the create and edit screens are refused.
This commit is contained in:
parent
6a3a902045
commit
4b4477d359
7 changed files with 1186 additions and 20 deletions
|
|
@ -1,19 +1,38 @@
|
|||
"""Forms for the ops portal.
|
||||
|
||||
The intake form is deliberately **stricter than the model**. ``HarvestIntake``
|
||||
allows almost everything to be blank, because records also arrive through the
|
||||
API, and the platform copes by flagging the gaps later: the EUDR completeness
|
||||
check has an "intake data" test whose whole job is to catch a missing date or
|
||||
weight. A portal form that let the same blanks through would simply move that
|
||||
problem to a screen where a person could have prevented it.
|
||||
Both forms here share a principle: **stricter than the model.**
|
||||
|
||||
So every field the checks read is required here — reference, collector group,
|
||||
origin zone, date, weight and grade — while the fields that are genuinely
|
||||
optional at intake (receiving officer, the supporting file's name) stay optional.
|
||||
The intake form is stricter about *completeness*. ``HarvestIntake`` allows almost
|
||||
everything to be blank, because records also arrive through the API, and the
|
||||
platform copes by flagging the gaps later: the EUDR completeness check has an
|
||||
"intake data" test whose whole job is to catch a missing date or weight. A portal
|
||||
form that let the same blanks through would simply move that problem to a screen
|
||||
where a person could have prevented it. So every field the checks read is
|
||||
required — reference, collector group, origin zone, date, weight and grade — and
|
||||
the fields that are genuinely optional at intake stay optional.
|
||||
|
||||
The batch form and the lineage formset are stricter about *contradiction*. A
|
||||
batch's status is not a free field: recording a QC result sets it to QC Pass or QC
|
||||
Fail, so the form refuses to let a person type over a quality failure. And an
|
||||
intake's weight is finite, so the formset refuses to allocate more of it than was
|
||||
received, counting every batch that has already drawn from it.
|
||||
|
||||
Both reasons are stated in the docstrings of the classes themselves, because a
|
||||
maintainer's instinct is otherwise to "fix" them back to match the model.
|
||||
"""
|
||||
from django import forms
|
||||
from django.db.models import Q, Sum
|
||||
from django.forms import modelformset_factory
|
||||
|
||||
from apps.compliance.models import CollectorGroup, HarvestIntake, OriginZone
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.compliance import services
|
||||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
Facility,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
OriginZone,
|
||||
)
|
||||
|
||||
# The visual grades the product specification defines.
|
||||
GRADE_CHOICES = [("A", "Grade A"), ("B", "Grade B"), ("C", "Grade C")]
|
||||
|
|
@ -154,3 +173,340 @@ class HarvestIntakeForm(forms.ModelForm):
|
|||
),
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batches
|
||||
#
|
||||
# The batch form exists for a different reason from the intake form: not to stop
|
||||
# incomplete records, but to stop **contradictory** ones. A batch's status is not
|
||||
# purely a human field — recording a QC result sets it to QC Pass or QC Fail — so
|
||||
# a free status dropdown would let someone quietly type over a quality failure.
|
||||
# The rules below are the whole point of the screen; read them before changing
|
||||
# them.
|
||||
|
||||
# The statuses the platform sets from a QC result, and which a person therefore
|
||||
# must not be able to type.
|
||||
QC_SET_STATUSES = [
|
||||
ProcessingBatch.Status.QC_PASS,
|
||||
ProcessingBatch.Status.QC_FAIL,
|
||||
]
|
||||
|
||||
# ...and of those, the one that is sticky: a failed batch cannot be moved out of
|
||||
# its failure by hand at all, only by a new result. QC Pass is not sticky, because
|
||||
# a passed batch is exactly the batch that goes to sea.
|
||||
STICKY_STATUSES = [ProcessingBatch.Status.QC_FAIL]
|
||||
|
||||
# The journey statuses, which a person does move by hand. The real world is
|
||||
# untidy: a batch can genuinely go back to processing.
|
||||
JOURNEY_STATUSES = [
|
||||
ProcessingBatch.Status.HARVESTED,
|
||||
ProcessingBatch.Status.PROCESSING,
|
||||
ProcessingBatch.Status.READY_TO_SHIP,
|
||||
ProcessingBatch.Status.IN_TRANSIT,
|
||||
ProcessingBatch.Status.AT_UK_PORT,
|
||||
ProcessingBatch.Status.CUSTOMS,
|
||||
ProcessingBatch.Status.DELIVERED,
|
||||
]
|
||||
|
||||
# A batch cannot be born mid-journey: it exists because material was processed.
|
||||
OPENING_STATUSES = [ProcessingBatch.Status.HARVESTED, ProcessingBatch.Status.PROCESSING]
|
||||
|
||||
|
||||
class BatchForm(forms.ModelForm):
|
||||
"""Create or correct a processing batch.
|
||||
|
||||
Two rules worth stating plainly, because they are the difference between a
|
||||
working screen and a dangerous one:
|
||||
|
||||
- **QC Fail is sticky.** A batch sitting on a failed QC result cannot be
|
||||
moved by hand at all. Only a new result — a retest — takes it off. To let
|
||||
the portal move it would be to give the screen a way to make a quality
|
||||
failure disappear.
|
||||
- **QC Pass can move on.** It is offered as the current value (so saving
|
||||
alone changes nothing) alongside the journey statuses, because a batch that
|
||||
has passed QC is exactly the batch that goes to sea.
|
||||
"""
|
||||
|
||||
status = forms.ChoiceField(
|
||||
choices=[],
|
||||
label="Status",
|
||||
help_text="Where the batch is in its journey.",
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
# Declared here so the required field opens on a plain instruction rather than
|
||||
# Django's bare "---------".
|
||||
grade = forms.ChoiceField(
|
||||
choices=[("", "Select the grade")] + GRADE_CHOICES,
|
||||
label="Grade",
|
||||
help_text="The visual grade recorded for the batch, as defined by the product specification.",
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProcessingBatch
|
||||
fields = [
|
||||
"batch_reference",
|
||||
"grade",
|
||||
"weight_kg",
|
||||
"harvest_date",
|
||||
"origin_facility",
|
||||
"notes",
|
||||
"status",
|
||||
]
|
||||
labels = {
|
||||
"batch_reference": "Batch reference",
|
||||
"weight_kg": "Weight (kg)",
|
||||
"harvest_date": "Harvest date",
|
||||
"origin_facility": "Origin facility",
|
||||
"notes": "Notes",
|
||||
}
|
||||
help_texts = {
|
||||
"batch_reference": "Must be unique. Suggested from the last batch recorded, and can be overwritten.",
|
||||
"weight_kg": "The batch weight as processed, in kilograms.",
|
||||
"harvest_date": "The date the underlying harvest was taken.",
|
||||
"origin_facility": "The processing site. New batches choose from the facility register; an existing batch recorded against a site that is no longer registered keeps its own value, flagged so the drift is visible.",
|
||||
"notes": "Anything the next person needs to know about this batch.",
|
||||
}
|
||||
widgets = {
|
||||
"batch_reference": forms.TextInput(
|
||||
attrs={"class": "form-control", "autocomplete": "off"}
|
||||
),
|
||||
"grade": forms.Select(attrs={"class": "form-select"}),
|
||||
"weight_kg": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
|
||||
),
|
||||
"harvest_date": forms.DateInput(
|
||||
attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"notes": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# `_state.adding`, not `pk is None`: ProcessingBatch's primary key is a
|
||||
# UUIDField with a default, so an unsaved instance already has a pk, and
|
||||
# `bool(instance.pk)` reads as "editing" for a brand-new batch.
|
||||
is_new = self.instance._state.adding
|
||||
locked = not is_new and self.instance.status in STICKY_STATUSES
|
||||
|
||||
if locked:
|
||||
# Shown, not editable. `disabled` means Django validates the initial
|
||||
# value, so the current status is the only choice on offer.
|
||||
self.fields["status"].choices = [
|
||||
(self.instance.status, self.instance.get_status_display())
|
||||
]
|
||||
self.fields["status"].disabled = True
|
||||
self.fields["status"].help_text = (
|
||||
"Set by the QC result and cannot be changed here. A new result — "
|
||||
"a retest — is what moves it."
|
||||
)
|
||||
elif not is_new:
|
||||
choices = list(JOURNEY_STATUSES)
|
||||
if self.instance.status == ProcessingBatch.Status.QC_PASS:
|
||||
# Offered so that saving alone leaves it alone, and so it can be
|
||||
# moved on to the journey statuses.
|
||||
choices.insert(0, ProcessingBatch.Status.QC_PASS)
|
||||
self.fields["status"].choices = [(s.value, s.label) for s in choices]
|
||||
else:
|
||||
self.fields["status"].choices = [
|
||||
(s.value, s.label) for s in OPENING_STATUSES
|
||||
]
|
||||
self.fields["status"].initial = ProcessingBatch.Status.HARVESTED
|
||||
|
||||
# The origin facility comes from the register. An existing batch whose
|
||||
# recorded name is not in the register keeps its own value as a choice —
|
||||
# history is not silently rewritten — but it is labelled as the odd one
|
||||
# out so the drift is visible.
|
||||
current = "" if is_new else (self.instance.origin_facility or "").strip()
|
||||
registered = [
|
||||
f.name for f in Facility.objects.filter(is_active=True).order_by("name")
|
||||
]
|
||||
choices = [(name, name) for name in registered]
|
||||
if current and current not in registered:
|
||||
choices.append((current, f"{current} — not in the facility register"))
|
||||
self.fields["origin_facility"] = forms.ChoiceField(
|
||||
choices=choices,
|
||||
required=True,
|
||||
label="Origin facility",
|
||||
help_text=self.Meta.help_texts["origin_facility"],
|
||||
widget=forms.Select(attrs={"class": "form-select"}),
|
||||
)
|
||||
self.fields["origin_facility"].initial = current or (
|
||||
registered[0] if len(registered) == 1 else ""
|
||||
)
|
||||
|
||||
for name in ("grade", "weight_kg", "harvest_date"):
|
||||
self.fields[name].required = True
|
||||
|
||||
def clean_batch_reference(self):
|
||||
reference = (self.cleaned_data["batch_reference"] or "").strip()
|
||||
if not reference:
|
||||
raise forms.ValidationError("A batch reference is required.")
|
||||
clashes = ProcessingBatch.objects.filter(batch_reference__iexact=reference)
|
||||
if not self.instance._state.adding:
|
||||
clashes = clashes.exclude(pk=self.instance.pk)
|
||||
if clashes.exists():
|
||||
raise forms.ValidationError(
|
||||
f"Batch {reference} already exists. References must be unique."
|
||||
)
|
||||
return reference
|
||||
|
||||
def clean_weight_kg(self):
|
||||
weight = self.cleaned_data["weight_kg"]
|
||||
if weight is None:
|
||||
raise forms.ValidationError("A weight is required.")
|
||||
if weight <= 0:
|
||||
raise forms.ValidationError("Weight must be greater than zero.")
|
||||
return weight
|
||||
|
||||
def clean_status(self):
|
||||
status = self.cleaned_data.get("status")
|
||||
# A locked batch's own status arrives with the form and must be allowed
|
||||
# through — it is only a *different* QC status that is a hand-setting.
|
||||
unchanged = not self.instance._state.adding and status == self.instance.status
|
||||
if status in QC_SET_STATUSES and not unchanged:
|
||||
raise forms.ValidationError(
|
||||
f"Batch status {ProcessingBatch.Status(status).label} is set by the "
|
||||
"QC result, not by hand."
|
||||
)
|
||||
if self.instance._state.adding and status not in OPENING_STATUSES:
|
||||
raise forms.ValidationError(
|
||||
"A new batch starts at Harvested or Processing."
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
class IntakeChoiceField(forms.ModelChoiceField):
|
||||
"""Intake dropdown that carries the facts needed to allocate from it.
|
||||
|
||||
An operator deciding how much of an intake can go into a batch needs the
|
||||
weight received and what is left of it. Both are read off annotations added by
|
||||
the view, so this costs no extra queries.
|
||||
|
||||
Kept short on purpose: a native ``<select>`` sizes itself to its longest
|
||||
option, so a long label — the collector group's full name, say — widens the
|
||||
column past the card and pushes the allocated-weight field out of sight.
|
||||
"""
|
||||
|
||||
def label_from_instance(self, intake):
|
||||
parts = [intake.intake_reference]
|
||||
if intake.weight_kg is not None:
|
||||
parts.append(f"{intake.weight_kg} kg received")
|
||||
elsewhere = getattr(intake, "allocated_elsewhere", None)
|
||||
if elsewhere and intake.weight_kg is not None:
|
||||
left = intake.weight_kg - elsewhere
|
||||
parts.append(f"{left} kg left" if left > 0 else "fully allocated")
|
||||
return " — ".join(parts)
|
||||
|
||||
|
||||
class BatchLineageForm(forms.ModelForm):
|
||||
"""One intake→batch allocation."""
|
||||
|
||||
harvest_intake = IntakeChoiceField(
|
||||
queryset=HarvestIntake.objects.none(),
|
||||
label="Harvest intake",
|
||||
empty_label="Select the harvest intake",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IntakeBatchLineage
|
||||
fields = ["harvest_intake", "weight_allocated_kg"]
|
||||
labels = {"weight_allocated_kg": "Allocated weight (kg)"}
|
||||
help_texts = {
|
||||
"weight_allocated_kg": "How much of this intake went into this batch."
|
||||
}
|
||||
widgets = {
|
||||
"weight_allocated_kg": forms.NumberInput(
|
||||
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, batch=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.batch = batch
|
||||
queryset = HarvestIntake.objects.select_related(
|
||||
"collector_group", "origin_zone"
|
||||
).order_by("intake_reference")
|
||||
if batch is not None:
|
||||
# What every *other* batch has taken from this intake, so the
|
||||
# dropdown can say so and the formset can refuse the over-allocation.
|
||||
queryset = queryset.annotate(
|
||||
allocated_elsewhere=Sum(
|
||||
"batch_lineage__weight_allocated_kg",
|
||||
filter=~Q(batch_lineage__batch=batch),
|
||||
)
|
||||
)
|
||||
self.fields["harvest_intake"].queryset = queryset
|
||||
self.fields["weight_allocated_kg"].required = True
|
||||
|
||||
def clean_weight_allocated_kg(self):
|
||||
weight = self.cleaned_data["weight_allocated_kg"]
|
||||
if weight is None:
|
||||
raise forms.ValidationError("An allocated weight is required.")
|
||||
if weight <= 0:
|
||||
raise forms.ValidationError("Allocated weight must be greater than zero.")
|
||||
return weight
|
||||
|
||||
|
||||
class BaseBatchLineageFormSet(forms.BaseModelFormSet):
|
||||
"""Refuses allocations that the intake cannot support.
|
||||
|
||||
The model has no such rule — ``weight_allocated_kg`` is a bare decimal — which
|
||||
is how the seeded data ended up allocating 800 kg of an 820 kg intake into two
|
||||
different batches. The rule enforced here counts **every** batch, not just
|
||||
this one, because the intake is a finite quantity of shea and the whole point
|
||||
of lineage is that it adds up.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, batch=None, **kwargs):
|
||||
self.batch = batch
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if any(self.errors):
|
||||
# Per-form errors already say what is wrong; piling cross-row errors
|
||||
# on top of them makes the screen unreadable.
|
||||
return
|
||||
|
||||
rows = [] # (form, intake, weight)
|
||||
seen = {}
|
||||
for form in self.forms:
|
||||
if not getattr(form, "cleaned_data", None):
|
||||
continue
|
||||
if form.cleaned_data.get("DELETE"):
|
||||
continue
|
||||
intake = form.cleaned_data.get("harvest_intake")
|
||||
weight = form.cleaned_data.get("weight_allocated_kg")
|
||||
if not intake or weight is None:
|
||||
continue
|
||||
if intake.pk in seen:
|
||||
form.add_error(
|
||||
"harvest_intake",
|
||||
forms.ValidationError(
|
||||
f"{intake.intake_reference} is already linked to this batch. "
|
||||
"Each intake appears once — add the two allocations together."
|
||||
),
|
||||
)
|
||||
continue
|
||||
seen[intake.pk] = True
|
||||
rows.append((form, intake, weight))
|
||||
|
||||
for form, intake, weight in rows:
|
||||
message = services.intake_allocation_error(
|
||||
intake, weight, exclude_batch=self.batch
|
||||
)
|
||||
if message:
|
||||
form.add_error(
|
||||
"weight_allocated_kg", forms.ValidationError(message)
|
||||
)
|
||||
|
||||
|
||||
BatchLineageFormSet = modelformset_factory(
|
||||
IntakeBatchLineage,
|
||||
form=BatchLineageForm,
|
||||
formset=BaseBatchLineageFormSet,
|
||||
extra=1,
|
||||
can_delete=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,28 @@
|
|||
"""The ops intake screens.
|
||||
"""The ops intake and batch screens.
|
||||
|
||||
Two things matter here and neither is cosmetic: that only operations can create
|
||||
intake records, and that the form refuses the incomplete records the platform's
|
||||
own checks exist to catch. The model tolerates a missing date, weight or zone
|
||||
because records also arrive by API; the portal form does not.
|
||||
records, and that the forms refuse what the platform's own checks exist to catch.
|
||||
The model tolerates a missing intake date, weight or zone because records also
|
||||
arrive by API — the portal form does not. And the model has no opinion at all
|
||||
about a batch's status or about how much of an intake can be allocated, which is
|
||||
where the real rules live: a QC failure must not be typable, and an intake cannot
|
||||
give away more shea than it received.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from apps.compliance.models import CollectorGroup, Facility, HarvestIntake, OriginZone
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
Facility,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
OriginZone,
|
||||
)
|
||||
|
||||
from .views import suggested_intake_reference
|
||||
from .views import suggested_batch_reference, suggested_intake_reference
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
|
@ -213,3 +223,427 @@ class SuggestedReferenceTests(IntakeTestCase):
|
|||
weight_kg=Decimal("10.00"), grade="A",
|
||||
)
|
||||
self.assertEqual(suggested_intake_reference(), "IXG-INTAKE-2026-9002")
|
||||
|
||||
|
||||
class BatchTestCase(TestCase):
|
||||
"""A zone, a group, a facility and three intakes — the world a batch lives in."""
|
||||
|
||||
def setUp(self):
|
||||
self.ops = User.objects.create_user(
|
||||
email="batch-ops@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Batch Officer", is_staff=True,
|
||||
)
|
||||
self.buyer = User.objects.create_user(
|
||||
email="batch-buyer@ixg.local", password="pass1234", role="buyer",
|
||||
full_name="Batch Buyer", is_staff=True,
|
||||
)
|
||||
self.facility = Facility.objects.create(
|
||||
name="IXG Test Processing Facility", country="Nigeria",
|
||||
region="Katsina State",
|
||||
)
|
||||
self.zone = OriginZone.objects.create(
|
||||
facility=self.facility, zone_code="NG-KT-ZBATCH", country="Nigeria",
|
||||
region="Katsina State", verification_status="verified",
|
||||
)
|
||||
self.group = CollectorGroup.objects.create(
|
||||
name="Batch Test Collective", contact_reference="CG-BATCH-001",
|
||||
origin_zone=self.zone,
|
||||
)
|
||||
self.intakes = [
|
||||
HarvestIntake.objects.create(
|
||||
intake_reference=f"IXG-INTAKE-2026-91{n:02d}", collector_group=self.group,
|
||||
origin_zone=self.zone, intake_date=f"2026-09-0{n}",
|
||||
weight_kg=Decimal(weight), grade="A", status="verified",
|
||||
)
|
||||
for n, weight in ((1, "820.00"), (2, "640.00"), (3, "600.00"))
|
||||
]
|
||||
self.client_ops = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
self.client_ops.force_login(self.ops)
|
||||
self.client_buyer = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
self.client_buyer.force_login(self.buyer)
|
||||
self.batch = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-2026-901", harvest_date="2026-09-04",
|
||||
weight_kg=Decimal("1500.00"), grade="A", status="harvested",
|
||||
origin_facility=self.facility.name, created_by=self.ops,
|
||||
)
|
||||
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"batch_reference": "IXG-BATCH-2026-902",
|
||||
"grade": "A",
|
||||
"weight_kg": "1200.00",
|
||||
"harvest_date": "2026-09-10",
|
||||
"origin_facility": self.facility.name,
|
||||
"notes": "Synthetic batch for tests.",
|
||||
"status": "harvested",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def errors_for(self, response, field=None):
|
||||
form = response.context["form"]
|
||||
if field:
|
||||
return " ".join(form.errors.get(field, []))
|
||||
return " ".join(str(v) for v in form.errors.values())
|
||||
|
||||
def lineage_payload(self, rows, initial=0):
|
||||
"""A formset POST body. Each row: {intake, weight, id?, delete?}."""
|
||||
data = {
|
||||
"lineage-TOTAL_FORMS": str(len(rows) + 1),
|
||||
"lineage-INITIAL_FORMS": str(initial),
|
||||
"lineage-MIN_NUM_FORMS": "0",
|
||||
"lineage-MAX_NUM_FORMS": "1000",
|
||||
}
|
||||
for i, row in enumerate(rows):
|
||||
data[f"lineage-{i}-harvest_intake"] = str(row.get("intake") or "")
|
||||
data[f"lineage-{i}-weight_allocated_kg"] = str(row.get("weight") or "")
|
||||
if row.get("id"):
|
||||
data[f"lineage-{i}-id"] = str(row["id"])
|
||||
if row.get("delete"):
|
||||
data[f"lineage-{i}-DELETE"] = "on"
|
||||
data[f"lineage-{len(rows)}-harvest_intake"] = ""
|
||||
data[f"lineage-{len(rows)}-weight_allocated_kg"] = ""
|
||||
return data
|
||||
|
||||
def lineage_errors(self, response):
|
||||
formset = response.context["formset"]
|
||||
return " ".join(
|
||||
str(e) for form in formset.forms for e in form.errors.values()
|
||||
)
|
||||
|
||||
|
||||
class BatchAccessTests(BatchTestCase):
|
||||
def test_ops_can_open_every_batch_screen(self):
|
||||
for path in (
|
||||
"/ops/batches/", "/ops/batches/new/",
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client_ops.get(path).status_code, 200)
|
||||
|
||||
def test_a_buyer_cannot_open_them(self):
|
||||
for path in (
|
||||
"/ops/batches/new/", f"/ops/batches/{self.batch.pk}/edit/",
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client_buyer.get(path).status_code, 403)
|
||||
|
||||
def test_a_buyer_cannot_post_a_new_batch(self):
|
||||
r = self.client_buyer.post("/ops/batches/new/", self.payload())
|
||||
self.assertEqual(r.status_code, 403)
|
||||
self.assertFalse(
|
||||
ProcessingBatch.objects.filter(batch_reference="IXG-BATCH-2026-902").exists()
|
||||
)
|
||||
|
||||
def test_a_buyer_cannot_post_an_edit(self):
|
||||
r = self.client_buyer.post(
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
self.payload(batch_reference="IXG-BATCH-2026-901", status="delivered"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
self.batch.refresh_from_db()
|
||||
self.assertEqual(self.batch.status, "harvested")
|
||||
|
||||
def test_the_list_offers_a_new_batch_control(self):
|
||||
r = self.client_ops.get("/ops/batches/")
|
||||
self.assertContains(r, "New batch")
|
||||
self.assertIn("/ops/batches/new/", r.content.decode())
|
||||
|
||||
|
||||
class BatchCreateTests(BatchTestCase):
|
||||
def test_a_complete_batch_is_created_and_sent_to_its_lineage(self):
|
||||
r = self.client_ops.post("/ops/batches/new/", self.payload())
|
||||
self.assertEqual(r.status_code, 302)
|
||||
batch = ProcessingBatch.objects.get(batch_reference="IXG-BATCH-2026-902")
|
||||
self.assertEqual(batch.weight_kg, Decimal("1200.00"))
|
||||
self.assertEqual(batch.origin_facility, self.facility.name)
|
||||
# Created by the signed-in user, and pointed straight at the lineage,
|
||||
# because a batch with no intake behind it is not traceable.
|
||||
self.assertEqual(batch.created_by, self.ops)
|
||||
self.assertEqual(r["Location"], f"/ops/batches/{batch.pk}/lineage/")
|
||||
|
||||
def test_the_form_suggests_the_next_reference(self):
|
||||
r = self.client_ops.get("/ops/batches/new/")
|
||||
self.assertContains(r, f'value="{suggested_batch_reference()}"')
|
||||
|
||||
def test_a_duplicate_reference_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/batches/new/", self.payload(batch_reference="IXG-BATCH-2026-901")
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn("already exists", self.errors_for(r, "batch_reference"))
|
||||
|
||||
def test_a_duplicate_differing_only_in_case_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/batches/new/", self.payload(batch_reference="ixg-batch-2026-901")
|
||||
)
|
||||
self.assertIn("already exists", self.errors_for(r, "batch_reference"))
|
||||
|
||||
def test_a_zero_or_negative_weight_is_refused(self):
|
||||
for weight in ("0", "-5"):
|
||||
with self.subTest(weight=weight):
|
||||
r = self.client_ops.post("/ops/batches/new/", self.payload(weight_kg=weight))
|
||||
self.assertIn("greater than zero", self.errors_for(r, "weight_kg"))
|
||||
|
||||
def test_the_fields_a_batch_needs_are_required(self):
|
||||
for field in ("batch_reference", "grade", "weight_kg", "harvest_date",
|
||||
"origin_facility", "status"):
|
||||
with self.subTest(field=field):
|
||||
r = self.client_ops.post("/ops/batches/new/", self.payload(**{field: ""}))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(
|
||||
self.errors_for(r, field), f"{field} was accepted blank"
|
||||
)
|
||||
|
||||
def test_a_new_batch_cannot_be_born_mid_journey(self):
|
||||
for status in ("in_transit", "delivered", "ready_to_ship"):
|
||||
with self.subTest(status=status):
|
||||
r = self.client_ops.post("/ops/batches/new/", self.payload(status=status))
|
||||
self.assertTrue(self.errors_for(r, "status"))
|
||||
|
||||
def test_a_qc_status_cannot_be_typed_by_hand(self):
|
||||
for status in ("qc_pass", "qc_fail"):
|
||||
with self.subTest(status=status):
|
||||
r = self.client_ops.post("/ops/batches/new/", self.payload(status=status))
|
||||
self.assertTrue(self.errors_for(r, "status"))
|
||||
self.assertFalse(
|
||||
ProcessingBatch.objects.filter(batch_reference="IXG-BATCH-2026-902").exists()
|
||||
)
|
||||
|
||||
def test_the_origin_facility_comes_from_the_register(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/batches/new/", self.payload(origin_facility="A site nobody registered")
|
||||
)
|
||||
self.assertTrue(self.errors_for(r, "origin_facility"))
|
||||
|
||||
|
||||
class BatchStatusRuleTests(BatchTestCase):
|
||||
"""The rule that makes the screen worth having."""
|
||||
|
||||
def test_a_journey_status_is_moved_by_hand(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
self.payload(batch_reference="IXG-BATCH-2026-901", status="ready_to_ship"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.batch.refresh_from_db()
|
||||
self.assertEqual(self.batch.status, "ready_to_ship")
|
||||
|
||||
def test_a_passed_batch_can_go_on_to_ship(self):
|
||||
self.batch.status = "qc_pass"
|
||||
self.batch.save()
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
self.payload(batch_reference="IXG-BATCH-2026-901", status="ready_to_ship"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.batch.refresh_from_db()
|
||||
self.assertEqual(self.batch.status, "ready_to_ship")
|
||||
|
||||
def test_a_failed_batch_keeps_its_failed_status(self):
|
||||
"""The screen must not be a way to make a quality failure disappear."""
|
||||
self.batch.status = "qc_fail"
|
||||
self.batch.save()
|
||||
r = self.client_ops.get(f"/ops/batches/{self.batch.pk}/edit/")
|
||||
html = r.content.decode()
|
||||
self.assertIn("Set by the QC result", html)
|
||||
# Only the current status is on offer, so it cannot be typed over.
|
||||
self.assertNotIn('value="ready_to_ship"', html)
|
||||
self.assertNotIn('value="delivered"', html)
|
||||
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
self.payload(batch_reference="IXG-BATCH-2026-901", status="ready_to_ship"),
|
||||
)
|
||||
self.batch.refresh_from_db()
|
||||
self.assertEqual(self.batch.status, "qc_fail")
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_a_qc_status_cannot_be_hand_set_on_a_journey_batch(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
self.payload(batch_reference="IXG-BATCH-2026-901", status="qc_fail"),
|
||||
)
|
||||
self.assertTrue(self.errors_for(r, "status"))
|
||||
self.batch.refresh_from_db()
|
||||
self.assertEqual(self.batch.status, "harvested")
|
||||
|
||||
def test_editing_updates_in_place(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
self.payload(batch_reference="IXG-BATCH-2026-901", weight_kg="1400.00"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.batch.refresh_from_db()
|
||||
self.assertEqual(self.batch.weight_kg, Decimal("1400.00"))
|
||||
self.assertEqual(ProcessingBatch.objects.count(), 1)
|
||||
|
||||
def test_an_existing_facility_that_is_not_registered_is_kept_and_flagged(self):
|
||||
"""History is not silently rewritten, but the drift is made visible."""
|
||||
self.batch.origin_facility = "ASI - Katsina State, Nigeria"
|
||||
self.batch.save()
|
||||
r = self.client_ops.get(f"/ops/batches/{self.batch.pk}/edit/")
|
||||
self.assertContains(r, "not in the facility register")
|
||||
|
||||
def test_saving_under_its_own_reference_is_not_a_duplicate(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/edit/",
|
||||
self.payload(batch_reference="IXG-BATCH-2026-901"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
|
||||
class BatchLineageTests(BatchTestCase):
|
||||
def test_the_screen_shows_what_is_allocated_and_the_batch_weight(self):
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=self.batch, harvest_intake=self.intakes[0],
|
||||
weight_allocated_kg=Decimal("500.00"),
|
||||
)
|
||||
r = self.client_ops.get(f"/ops/batches/{self.batch.pk}/lineage/")
|
||||
self.assertContains(r, "500.00")
|
||||
self.assertContains(r, "1500.00")
|
||||
|
||||
def test_an_allocation_is_created_against_the_batch(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload([{"intake": self.intakes[0].pk, "weight": "800.00"}]),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
row = IntakeBatchLineage.objects.get(batch=self.batch)
|
||||
self.assertEqual(row.harvest_intake, self.intakes[0])
|
||||
self.assertEqual(row.weight_allocated_kg, Decimal("800.00"))
|
||||
|
||||
def test_the_batch_detail_page_shows_the_lineage(self):
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=self.batch, harvest_intake=self.intakes[2],
|
||||
weight_allocated_kg=Decimal("600.00"),
|
||||
)
|
||||
r = self.client_ops.get(f"/ops/batches/{self.batch.pk}/")
|
||||
self.assertContains(r, "Intake Lineage")
|
||||
self.assertContains(r, self.intakes[2].intake_reference)
|
||||
self.assertContains(r, "600.00")
|
||||
|
||||
def test_a_batch_with_no_lineage_is_told_so(self):
|
||||
r = self.client_ops.get(f"/ops/batches/{self.batch.pk}/")
|
||||
self.assertContains(r, "No harvest intakes are linked to this batch")
|
||||
|
||||
def test_more_than_the_intake_received_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload([{"intake": self.intakes[0].pk, "weight": "900.00"}]),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn("cannot be allocated", self.lineage_errors(r))
|
||||
self.assertFalse(IntakeBatchLineage.objects.exists())
|
||||
|
||||
def test_an_intake_already_taken_by_another_batch_is_refused(self):
|
||||
"""The rule that counts every batch — and the defect in the old seed data."""
|
||||
other = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-2026-903", weight_kg=Decimal("800.00"),
|
||||
grade="A", status="processing", origin_facility=self.facility.name,
|
||||
)
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=other, harvest_intake=self.intakes[0],
|
||||
weight_allocated_kg=Decimal("800.00"),
|
||||
)
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload([{"intake": self.intakes[0].pk, "weight": "800.00"}]),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
message = self.lineage_errors(r)
|
||||
self.assertIn("already allocated", message)
|
||||
self.assertIn("IXG-BATCH-2026-903", message)
|
||||
self.assertEqual(IntakeBatchLineage.objects.filter(batch=self.batch).count(), 0)
|
||||
|
||||
def test_what_is_left_of_an_intake_can_still_be_allocated(self):
|
||||
other = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-2026-904", weight_kg=Decimal("500.00"),
|
||||
grade="A", status="processing", origin_facility=self.facility.name,
|
||||
)
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=other, harvest_intake=self.intakes[0],
|
||||
weight_allocated_kg=Decimal("500.00"),
|
||||
)
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload([{"intake": self.intakes[0].pk, "weight": "300.00"}]),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertEqual(
|
||||
IntakeBatchLineage.objects.get(batch=self.batch).weight_allocated_kg,
|
||||
Decimal("300.00"),
|
||||
)
|
||||
|
||||
def test_the_same_intake_twice_in_one_batch_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload([
|
||||
{"intake": self.intakes[0].pk, "weight": "200.00"},
|
||||
{"intake": self.intakes[0].pk, "weight": "300.00"},
|
||||
]),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn("already linked to this batch", self.lineage_errors(r))
|
||||
|
||||
def test_a_zero_allocation_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload([{"intake": self.intakes[0].pk, "weight": "0"}]),
|
||||
)
|
||||
self.assertTrue(self.lineage_errors(r))
|
||||
|
||||
def test_an_allocation_can_be_corrected_and_removed(self):
|
||||
row = IntakeBatchLineage.objects.create(
|
||||
batch=self.batch, harvest_intake=self.intakes[0],
|
||||
weight_allocated_kg=Decimal("500.00"),
|
||||
)
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload(
|
||||
[{"id": row.pk, "intake": self.intakes[0].pk, "weight": "520.00"}],
|
||||
initial=1,
|
||||
),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
row.refresh_from_db()
|
||||
self.assertEqual(row.weight_allocated_kg, Decimal("520.00"))
|
||||
|
||||
r = self.client_ops.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload(
|
||||
[{"id": row.pk, "intake": self.intakes[0].pk, "weight": "520.00",
|
||||
"delete": True}],
|
||||
initial=1,
|
||||
),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertFalse(IntakeBatchLineage.objects.filter(batch=self.batch).exists())
|
||||
# The intake itself is untouched: removing a link is not deleting material.
|
||||
self.assertTrue(HarvestIntake.objects.filter(pk=self.intakes[0].pk).exists())
|
||||
|
||||
def test_a_buyer_cannot_post_an_allocation(self):
|
||||
r = self.client_buyer.post(
|
||||
f"/ops/batches/{self.batch.pk}/lineage/",
|
||||
self.lineage_payload([{"intake": self.intakes[0].pk, "weight": "500.00"}]),
|
||||
)
|
||||
self.assertEqual(r.status_code, 403)
|
||||
self.assertFalse(IntakeBatchLineage.objects.exists())
|
||||
|
||||
|
||||
class SuggestedBatchReferenceTests(BatchTestCase):
|
||||
def test_the_suggestion_continues_the_year_sequence(self):
|
||||
self.assertEqual(suggested_batch_reference(), "IXG-BATCH-2026-902")
|
||||
|
||||
def test_the_suggestion_ignores_other_years_and_odd_references(self):
|
||||
ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-2025-9999", weight_kg=Decimal("10.00"),
|
||||
grade="A", status="harvested",
|
||||
)
|
||||
ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-TEST-001", weight_kg=Decimal("10.00"),
|
||||
grade="A", status="harvested",
|
||||
)
|
||||
self.assertEqual(suggested_batch_reference(), "IXG-BATCH-2026-902")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ app_name = 'ops'
|
|||
urlpatterns = [
|
||||
path('', views.DashboardView.as_view(), name='dashboard'),
|
||||
path('batches/', views.BatchListView.as_view(), name='batch_list'),
|
||||
path('batches/new/', views.BatchCreateView.as_view(), name='batch_create'),
|
||||
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
|
||||
path('batches/<uuid:pk>/edit/', views.BatchUpdateView.as_view(), name='batch_update'),
|
||||
path('batches/<uuid:pk>/lineage/', views.BatchLineageView.as_view(), name='batch_lineage'),
|
||||
path('intakes/', views.IntakeListView.as_view(), name='intake_list'),
|
||||
path('intakes/new/', views.IntakeCreateView.as_view(), name='intake_create'),
|
||||
path('intakes/<uuid:pk>/edit/', views.IntakeUpdateView.as_view(), name='intake_update'),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from django.urls import reverse
|
|||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView, ListView, DetailView, CreateView, UpdateView
|
||||
from django.views import View
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.qc.models import QCResult
|
||||
|
|
@ -18,10 +18,15 @@ from apps.documents.downloads import file_response
|
|||
from apps.documents.models import Document, CertificateOfAnalysis
|
||||
from apps.esg.models import ESGFieldReport
|
||||
from apps.notifications.models import Notification
|
||||
from apps.compliance.models import CollectorGroup, DueDiligenceCase, HarvestIntake
|
||||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
DueDiligenceCase,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
)
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
from .forms import HarvestIntakeForm
|
||||
from .forms import BatchForm, BatchLineageFormSet, HarvestIntakeForm
|
||||
|
||||
|
||||
def suggested_intake_reference(today=None):
|
||||
|
|
@ -42,6 +47,24 @@ def suggested_intake_reference(today=None):
|
|||
return f"{prefix}{highest + 1:04d}"
|
||||
|
||||
|
||||
def suggested_batch_reference(today=None):
|
||||
"""Next batch reference, continuing this year's sequence. Same reasoning.
|
||||
|
||||
Three digits, because that is the shape the existing references have
|
||||
(``IXG-BATCH-2026-014``); it widens by itself past 999.
|
||||
"""
|
||||
year = (today or timezone.localdate()).year
|
||||
prefix = f"IXG-BATCH-{year}-"
|
||||
highest = 0
|
||||
for ref in ProcessingBatch.objects.filter(
|
||||
batch_reference__startswith=prefix
|
||||
).values_list("batch_reference", flat=True):
|
||||
tail = ref[len(prefix):]
|
||||
if tail.isdigit():
|
||||
highest = max(highest, int(tail))
|
||||
return f"{prefix}{highest + 1:03d}"
|
||||
|
||||
|
||||
class OpsRequiredMixin(UserPassesTestMixin):
|
||||
def test_func(self):
|
||||
user = self.request.user
|
||||
|
|
@ -114,9 +137,150 @@ class BatchDetailView(OpsRequiredMixin, DetailView):
|
|||
ctx['capa_records'] = CAPARecord.objects.filter(batch=batch)
|
||||
ctx['documents'] = Document.objects.filter(batch=batch)
|
||||
ctx['esg_reports'] = ESGFieldReport.objects.filter(batch=batch)
|
||||
lineage = batch.intake_lineage.select_related(
|
||||
'harvest_intake',
|
||||
'harvest_intake__origin_zone',
|
||||
'harvest_intake__collector_group',
|
||||
).order_by('harvest_intake__intake_reference')
|
||||
ctx['lineage'] = lineage
|
||||
ctx['lineage_allocated'] = sum((row.weight_allocated_kg or 0) for row in lineage)
|
||||
if batch.weight_kg is not None:
|
||||
ctx['lineage_difference'] = batch.weight_kg - ctx['lineage_allocated']
|
||||
return ctx
|
||||
|
||||
|
||||
class BatchCreateView(OpsRequiredMixin, CreateView):
|
||||
model = ProcessingBatch
|
||||
form_class = BatchForm
|
||||
template_name = 'ops/batch_form.html'
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial['batch_reference'] = suggested_batch_reference()
|
||||
initial['harvest_date'] = timezone.localdate()
|
||||
initial['status'] = ProcessingBatch.Status.HARVESTED
|
||||
return initial
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New processing batch'
|
||||
ctx['submit_label'] = 'Create batch'
|
||||
# The template cannot ask the instance whether it is new — ProcessingBatch's
|
||||
# primary key is a UUID with a default, so an unsaved batch already has one,
|
||||
# and `_state` is unreachable from a template. The view knows, so it says.
|
||||
ctx['creating'] = True
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.created_by = self.request.user
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Batch {self.object.batch_reference} created. "
|
||||
"Allocate its harvest intakes next — a batch with no lineage fails "
|
||||
"the due-diligence lineage check.",
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
# Straight to the lineage, because a batch with no intake behind it is
|
||||
# not traceable and the check will say so.
|
||||
return reverse('ops:batch_lineage', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class BatchUpdateView(OpsRequiredMixin, UpdateView):
|
||||
model = ProcessingBatch
|
||||
form_class = BatchForm
|
||||
template_name = 'ops/batch_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit batch {self.object.batch_reference}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
ctx['creating'] = False
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Batch {self.object.batch_reference} updated."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:batch_detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class BatchLineageView(OpsRequiredMixin, View):
|
||||
"""Allocate harvest intakes to a batch, and correct what is already there.
|
||||
|
||||
This is the screen that closes the lineage gap in walkthrough steps 3 and 4:
|
||||
the link between an intake and a batch, with the weight that went into it, is
|
||||
created here rather than in the administration area. The formset refuses an
|
||||
allocation the intake cannot support — see
|
||||
``apps/ops/forms.py::BaseBatchLineageFormSet``.
|
||||
"""
|
||||
|
||||
template_name = 'ops/batch_lineage.html'
|
||||
|
||||
def get_batch(self):
|
||||
return get_object_or_404(ProcessingBatch, pk=self.kwargs['pk'])
|
||||
|
||||
def get_formset(self, batch, data=None):
|
||||
queryset = IntakeBatchLineage.objects.filter(batch=batch).select_related(
|
||||
'harvest_intake'
|
||||
).order_by('harvest_intake__intake_reference')
|
||||
return BatchLineageFormSet(
|
||||
data,
|
||||
queryset=queryset,
|
||||
prefix='lineage',
|
||||
batch=batch,
|
||||
form_kwargs={'batch': batch},
|
||||
)
|
||||
|
||||
def get_context_data(self, batch, formset):
|
||||
rows = list(batch.intake_lineage.select_related(
|
||||
'harvest_intake', 'harvest_intake__origin_zone',
|
||||
'harvest_intake__collector_group',
|
||||
).order_by('harvest_intake__intake_reference'))
|
||||
allocated = sum((row.weight_allocated_kg or 0) for row in rows)
|
||||
return {
|
||||
'batch': batch,
|
||||
'formset': formset,
|
||||
'lineage_rows': rows,
|
||||
'total_allocated': allocated,
|
||||
'difference': (batch.weight_kg - allocated)
|
||||
if batch.weight_kg is not None else None,
|
||||
}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
batch = self.get_batch()
|
||||
formset = self.get_formset(batch)
|
||||
return render(
|
||||
request, self.template_name, self.get_context_data(batch, formset)
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
batch = self.get_batch()
|
||||
formset = self.get_formset(batch, data=request.POST)
|
||||
if formset.is_valid():
|
||||
# A plain ModelFormSet does not know the batch, so the new rows get it
|
||||
# here; the deleted ones are removed explicitly.
|
||||
for obj in formset.save(commit=False):
|
||||
obj.batch = batch
|
||||
obj.save()
|
||||
for obj in formset.deleted_objects:
|
||||
obj.delete()
|
||||
messages.success(
|
||||
request,
|
||||
f"Intake lineage for {batch.batch_reference} updated.",
|
||||
)
|
||||
return redirect('ops:batch_detail', pk=batch.pk)
|
||||
return render(
|
||||
request, self.template_name, self.get_context_data(batch, formset)
|
||||
)
|
||||
|
||||
|
||||
class QCListView(OpsRequiredMixin, ListView):
|
||||
model = QCResult
|
||||
template_name = 'ops/qc_list.html'
|
||||
|
|
|
|||
92
templates/ops/batch_form.html
Normal file
92
templates/ops/batch_form.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}{{ page_title }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>{{ page_title }}</h2>
|
||||
<a href="{% url 'ops:batch_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<div class="stat-card">
|
||||
<form method="post" novalidate id="batch-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">
|
||||
{{ field.label }}{% if field.field.required and not field.field.disabled %} <span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ submit_label }}</button>
|
||||
<a href="{% url 'ops:batch_list' %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-diagram-3"></i> What a batch is</h6>
|
||||
<p class="small mb-2">
|
||||
The batch is the spine of the story: QC results, the certificate of
|
||||
analysis, the due-diligence case, shipments and the buyer's view all hang
|
||||
off it. If the reference is wrong, everything downstream is wrong with it.
|
||||
</p>
|
||||
<p class="small mb-0">
|
||||
Creating a batch is only half of it. It also needs its <strong>harvest
|
||||
intakes</strong> allocated to it, or it cannot be traced back to a
|
||||
collector group and an origin zone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-shield-check"></i> Who sets the status</h6>
|
||||
<ul class="small mb-2 ps-3">
|
||||
<li><strong>Harvested, Processing, Ready to Ship, In Transit, At UK Port,
|
||||
Customs, Delivered</strong> — moved here, by you.</li>
|
||||
<li><strong>QC Fail</strong> — never offered here; set by the platform
|
||||
when a QC result is recorded (step 5), because a quality failure must not
|
||||
be something a person can type over.</li>
|
||||
<li><strong>QC Pass</strong> — appears only as the current value when
|
||||
editing a batch that has already passed, so saving alone leaves it alone
|
||||
and it can be moved on to the journey statuses.</li>
|
||||
</ul>
|
||||
<p class="small mb-0">
|
||||
A batch sitting on a failed result cannot be moved at all until a new
|
||||
result — a retest — is recorded against it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if not creating %}
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-basket"></i> Harvest intakes</h6>
|
||||
<p class="small mb-2">
|
||||
Which intakes went into this batch, and how much of each. Allocated from
|
||||
the intakes screen for the batch.
|
||||
</p>
|
||||
<a href="{% url 'ops:batch_lineage' form.instance.pk %}" class="btn btn-sm btn-outline-primary">Edit intake lineage</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-basket"></i> Harvest intakes</h6>
|
||||
<p class="small mb-2">
|
||||
Once the batch exists you will be taken straight to its harvest intakes,
|
||||
so you can allocate what went into it. A batch with no intake linked to it
|
||||
cannot be traced back to a collector group and an origin zone.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
114
templates/ops/batch_lineage.html
Normal file
114
templates/ops/batch_lineage.html
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Intake lineage - {{ batch.batch_reference }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Intake lineage: {{ batch.batch_reference }}</h2>
|
||||
<a href="{% url 'ops:batch_detail' batch.pk %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back to the batch</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="stat-card">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{{ formset.management_form }}
|
||||
|
||||
{% if formset.non_form_errors %}
|
||||
{% for e in formset.non_form_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- table-layout: fixed, or the intake dropdown's intrinsic width (a native
|
||||
select sizes to its longest option) pushes the weight field and the
|
||||
Remove box off the card. -->
|
||||
<table class="table table-sm align-middle mb-3" style="table-layout: fixed;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Harvest intake</th>
|
||||
<th style="width: 10rem;">Allocated (kg)</th>
|
||||
<th style="width: 6rem;">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in formset.forms %}
|
||||
<tr>
|
||||
{% for hidden in f.hidden_fields %}{{ hidden }}{% endfor %}
|
||||
<td>
|
||||
{{ f.harvest_intake }}
|
||||
{% for e in f.harvest_intake.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{{ f.weight_allocated_kg }}
|
||||
{% for e in f.weight_allocated_kg.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.instance.pk %}{{ f.DELETE }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save lineage</button>
|
||||
<a href="{% url 'ops:batch_detail' batch.pk %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-calculator"></i> Weights</h6>
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><td class="text-secondary">Allocated to this batch</td><td>{{ total_allocated }} kg</td></tr>
|
||||
<tr><td class="text-secondary">Batch weight</td><td>{{ batch.weight_kg|default:'-' }} kg</td></tr>
|
||||
{% if difference is not None %}
|
||||
<tr>
|
||||
<td class="text-secondary">Difference</td>
|
||||
<td>{{ difference }} kg</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
<p class="small text-secondary mt-2 mb-0">
|
||||
The platform shows these two figures side by side; it does not reconcile
|
||||
them. A difference where the batch is heavier means part of the batch is
|
||||
not traced to an intake; allocations above the batch weight are the usual
|
||||
processing loss.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-shield-check"></i> What this feeds</h6>
|
||||
<ul class="small mb-2 ps-3">
|
||||
<li><strong>Lineage</strong> — the batch needs at least one intake</li>
|
||||
<li><strong>Geolocation</strong> — every linked intake must point at a
|
||||
<em>verified</em> origin zone</li>
|
||||
<li><strong>Intake data</strong> — every linked intake needs a date and
|
||||
a weight</li>
|
||||
</ul>
|
||||
<p class="small mb-0">
|
||||
This table is what the due-diligence case for {{ batch.batch_reference }}
|
||||
reads, and what a buyer or an auditor traces the batch back through.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-exclamation-triangle"></i> One intake, limited weight</h6>
|
||||
<p class="small mb-2">
|
||||
An intake received a finite weight of shea, and what is allocated out of it
|
||||
is counted across <strong>every</strong> batch. An allocation that would
|
||||
take more than the intake received is refused, whether the excess is over
|
||||
the intake's own weight or over what other batches have already taken.
|
||||
</p>
|
||||
<p class="small mb-0">
|
||||
Removing a row here deletes the link only — the intake record itself
|
||||
is untouched.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -4,7 +4,10 @@
|
|||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Processing Batches</h2>
|
||||
<span class="text-secondary">{{ page_obj.paginator.count }} total</span>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:batch_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New batch</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue