Add a shipment entry screen to the ops portal

Walkthrough step 11 said shipment records came through the API or the admin
area, because the portal was read-only. The list and detail pages now carry New
shipment and Edit shipment controls, with /ops/shipments/new/ and
/ops/shipments/<uuid>/edit/ mirroring the intake and batch screens.

The form requires the fields the buyer's page reads — status, vessel, container,
ETD and ETA — and refuses a departure after the arrival. The bill of lading and
the forwarder stay optional, and the ports keep their model defaults. A batch
that already has a shipment is flagged in the dropdown and warned about after
saving rather than blocked, because a split consignment is legitimate. The batch
dropdown labels each batch with its own journey status, and a card explains that
the two statuses are independent.

Also fixes a pre-existing defect the new screen would have shared: the native
date inputs were rendered in the en-gb d/m/Y format, which the browser discards,
so the intake and batch edit forms silently opened with empty dates. They now
carry the ISO format a native date input requires.
This commit is contained in:
ixgadmin 2026-09-17 16:03:09 +00:00
parent b376c30300
commit 5e201b2035
6 changed files with 341 additions and 7 deletions

View file

@ -21,7 +21,7 @@ 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.db.models import Count, Q, Sum
from django.forms import modelformset_factory
from apps.batches.models import ProcessingBatch
@ -33,6 +33,7 @@ from apps.compliance.models import (
IntakeBatchLineage,
OriginZone,
)
from apps.shipments.models import Shipment
# The visual grades the product specification defines.
GRADE_CHOICES = [("A", "Grade A"), ("B", "Grade B"), ("C", "Grade C")]
@ -118,8 +119,11 @@ class HarvestIntakeForm(forms.ModelForm):
),
"collector_group": forms.Select(attrs={"class": "form-select"}),
"origin_zone": forms.Select(attrs={"class": "form-select"}),
# A native date input only accepts the ISO format. Without this the
# en-gb locale renders "04/09/2026", which the browser silently
# discards, leaving the field empty on an edit.
"intake_date": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"weight_kg": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
@ -277,7 +281,7 @@ class BatchForm(forms.ModelForm):
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
),
"harvest_date": forms.DateInput(
attrs={"class": "form-control", "type": "date"}
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"notes": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
}
@ -510,3 +514,145 @@ BatchLineageFormSet = modelformset_factory(
extra=1,
can_delete=True,
)
# ---------------------------------------------------------------------------
# Shipments
#
# The shipment form is stricter about *completeness*. The model tolerates a
# blank vessel, a blank container and a missing date, because records also
# arrive through the API. But a shipment is what the buyer's page shows once a
# batch is on the water, so the portal form requires the fields the buyer
# reads — status, vessel, container, ETD and ETA. The bill of lading and the
# forwarder arrive later in the process and stay optional, and the ports keep
# their model defaults. A departure after the arrival is refused: it is a typo,
# not a plan.
#
# The shipment's status and the batch's journey status are separate. The batch
# dropdown labels each batch with its own status so the two can be seen side by
# side, but nothing here moves the batch, and the batch screen does not move a
# shipment.
class BatchChoiceField(forms.ModelChoiceField):
"""Batch dropdown that carries the batch's own journey status.
The two statuses are independent, so showing the batch's current one here
lets an operator see them together without a second lookup. The count of
*other* shipments on the batch is shown too, so a likely duplicate is
visible before saving — the form does not block one, because a split
consignment is legitimate.
"""
def label_from_instance(self, batch):
label = f"{batch.batch_reference} — {batch.get_status_display()}"
others = getattr(batch, "shipment_count", 0)
if others:
label += f" ({others} other shipment{'s' if others != 1 else ''})"
return label
class ShipmentForm(forms.ModelForm):
"""Create or correct a shipment."""
batch = BatchChoiceField(
queryset=ProcessingBatch.objects.order_by("-created_at"),
label="Batch",
help_text=(
"The consignment's batch. Its own journey status is shown here; the "
"shipment status below is separate."
),
empty_label="Select the batch",
)
status = forms.ChoiceField(
choices=Shipment.Status.choices,
label="Shipment status",
help_text=(
"Where the consignment is. It moves freely between these; the buyer's "
"page reflects the new status immediately."
),
widget=forms.Select(attrs={"class": "form-select"}),
)
class Meta:
model = Shipment
fields = [
"batch",
"status",
"origin_port",
"destination_port",
"vessel_name",
"container_ref",
"etd_nigeria",
"eta_uk",
"bl_number",
"freight_forwarder",
]
labels = {
"origin_port": "Origin port",
"destination_port": "Destination port",
"vessel_name": "Vessel name",
"container_ref": "Container reference",
"etd_nigeria": "ETD Nigeria",
"eta_uk": "ETA UK",
"bl_number": "Bill of lading number",
"freight_forwarder": "Freight forwarder",
}
help_texts = {
"origin_port": "Where it leaves Nigeria.",
"destination_port": "Where it arrives in the United Kingdom.",
"vessel_name": "The vessel carrying it.",
"container_ref": "The container reference the buyer is given.",
"etd_nigeria": "Estimated departure from the Nigerian port.",
"eta_uk": "Estimated arrival in the United Kingdom.",
"bl_number": "Optional — the bill of lading usually arrives after booking.",
"freight_forwarder": "Optional — the forwarding agent.",
}
widgets = {
"origin_port": forms.TextInput(attrs={"class": "form-control"}),
"destination_port": forms.TextInput(attrs={"class": "form-control"}),
"vessel_name": forms.TextInput(attrs={"class": "form-control"}),
"container_ref": forms.TextInput(attrs={"class": "form-control"}),
"etd_nigeria": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"eta_uk": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"bl_number": forms.TextInput(attrs={"class": "form-control"}),
"freight_forwarder": forms.TextInput(attrs={"class": "form-control"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# The model tolerates blanks and missing dates; the buyer's page does not.
for name in (
"batch",
"status",
"vessel_name",
"container_ref",
"etd_nigeria",
"eta_uk",
):
self.fields[name].required = True
# Count the *other* shipments on each batch, so the dropdown can flag a
# batch that already has one. Excluding this shipment means an edit does
# not count itself.
others = ~Q(shipments=self.instance) if self.instance.pk else Q()
self.fields["batch"].queryset = ProcessingBatch.objects.annotate(
shipment_count=Count("shipments", filter=others)
).order_by("-created_at")
def clean(self):
cleaned = super().clean()
etd = cleaned.get("etd_nigeria")
eta = cleaned.get("eta_uk")
if etd and eta and etd > eta:
self.add_error(
"eta_uk",
forms.ValidationError(
"The ETA cannot be before the ETD. Check the two dates."
),
)
return cleaned

View file

@ -16,7 +16,9 @@ urlpatterns = [
path('qc/', views.QCListView.as_view(), name='qc_list'),
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),
path('shipments/new/', views.ShipmentCreateView.as_view(), name='shipment_new'),
path('shipments/<uuid:pk>/', views.ShipmentDetailView.as_view(), name='shipment_detail'),
path('shipments/<uuid:pk>/edit/', views.ShipmentUpdateView.as_view(), name='shipment_update'),
path('capa/', views.CAPAListView.as_view(), name='capa_list'),
path('capa/<uuid:pk>/', views.CAPADetailView.as_view(), name='capa_detail'),
path('documents/', views.DocumentListView.as_view(), name='document_list'),

View file

@ -26,7 +26,7 @@ from apps.compliance.models import (
)
from apps.guides.views import GuideDetailView as BaseGuideDetailView
from apps.guides.views import GuideIndexView as BaseGuideIndexView
from .forms import BatchForm, BatchLineageFormSet, HarvestIntakeForm
from .forms import BatchForm, BatchLineageFormSet, HarvestIntakeForm, ShipmentForm
def suggested_intake_reference(today=None):
@ -334,6 +334,92 @@ class ShipmentDetailView(OpsRequiredMixin, DetailView):
context_object_name = 'shipment'
class ShipmentFormContextMixin:
"""Shared context for the shipment create and edit forms.
The batch's own status is shown on the form through the batch dropdown's
label, and the other shipments on the batch are listed here so an operator
can see a possible duplicate before saving — the form does not block one,
because a split consignment is legitimate.
"""
template_name = 'ops/shipment_form.html'
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
shipment = getattr(self, 'object', None)
if shipment is not None and shipment.pk:
ctx['other_shipments'] = shipment.batch.shipments.exclude(pk=shipment.pk)
else:
ctx['other_shipments'] = Shipment.objects.none()
return ctx
class ShipmentCreateView(ShipmentFormContextMixin, OpsRequiredMixin, CreateView):
model = Shipment
form_class = ShipmentForm
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['page_title'] = 'New shipment'
ctx['submit_label'] = 'Create shipment'
return ctx
def form_valid(self, form):
batch = form.instance.batch
existing = Shipment.objects.filter(batch=batch).count()
response = super().form_valid(form)
messages.success(
self.request, f"Shipment for {batch.batch_reference} created."
)
if existing:
messages.warning(
self.request,
f"{batch.batch_reference} already had {existing} shipment(s). This "
"was saved as a further consignment — check it is not a duplicate.",
)
return response
def get_success_url(self):
return reverse('ops:shipment_detail', kwargs={'pk': self.object.pk})
class ShipmentUpdateView(ShipmentFormContextMixin, OpsRequiredMixin, UpdateView):
model = Shipment
form_class = ShipmentForm
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['page_title'] = (
f"Edit shipment for {self.object.batch.batch_reference}"
)
ctx['submit_label'] = 'Save changes'
return ctx
def form_valid(self, form):
batch = form.instance.batch
previous_batch_id = (
Shipment.objects.filter(pk=self.object.pk)
.values_list('batch_id', flat=True)
.first()
)
others = Shipment.objects.filter(batch=batch).exclude(pk=self.object.pk).count()
response = super().form_valid(form)
messages.success(
self.request, f"Shipment for {batch.batch_reference} updated."
)
if others and previous_batch_id != batch.pk:
messages.warning(
self.request,
f"{batch.batch_reference} already had {others} shipment(s). This "
"was saved as a further consignment — check it is not a duplicate.",
)
return response
def get_success_url(self):
return reverse('ops:shipment_detail', kwargs={'pk': self.object.pk})
class CAPAListView(OpsRequiredMixin, ListView):
model = CAPARecord
template_name = 'ops/capa_list.html'

View file

@ -4,15 +4,23 @@
<div class="page-header d-flex justify-content-between align-items-center">
<h2>Shipment: {{ shipment.batch.batch_reference }}</h2>
<a href="{% url 'ops:shipment_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
<div>
<a href="{% url 'ops:shipment_update' shipment.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i> Edit shipment</a>
<a href="{% url 'ops:shipment_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
</div>
</div>
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
<div class="row g-3">
<div class="col-md-6">
<div class="stat-card">
<table class="table table-sm table-borderless mb-0">
<tr><td class="text-secondary">Batch</td><td><a href="{% url 'ops:batch_detail' shipment.batch.pk %}">{{ shipment.batch.batch_reference }}</a></td></tr>
<tr><td class="text-secondary">Status</td><td><span class="badge bg-secondary badge-status">{{ shipment.get_status_display }}</span></td></tr>
<tr><td class="text-secondary">Batch status</td><td><span class="badge bg-secondary badge-status">{{ shipment.batch.get_status_display }}</span></td></tr>
<tr><td class="text-secondary">Shipment status</td><td><span class="badge bg-secondary badge-status">{{ shipment.get_status_display }}</span></td></tr>
<tr><td class="text-secondary">Origin Port</td><td>{{ shipment.origin_port }}</td></tr>
<tr><td class="text-secondary">Destination Port</td><td>{{ shipment.destination_port }}</td></tr>
<tr><td class="text-secondary">Vessel Name</td><td>{{ shipment.vessel_name|default:'-' }}</td></tr>

View file

@ -0,0 +1,89 @@
{% 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:shipment_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="shipment-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 %} <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:shipment_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-truck"></i> What a shipment is</h6>
<p class="small mb-2">
The consignment itself: the vessel, the container and the two dates the
buyer asks about most. It hangs off a batch, and the buyer sees it on
that batch's page.
</p>
<p class="small mb-0">
The record holds the <strong>current</strong> status, not a history of it.
</p>
</div>
<div class="stat-card mb-3">
<h6 class="mb-3"><i class="bi bi-diagram-3"></i> Two separate statuses</h6>
<p class="small mb-2">
The <strong>batch</strong> has its own journey status (Harvested &hellip;
Delivered), moved on the batch screen and shown next to the batch in the
dropdown above. The <strong>shipment</strong> status is the one below, and
it moves freely between its seven values.
</p>
<p class="small mb-0">
They are independent: setting a shipment status here does not move the
batch, and the batch screen does not move the shipment.
</p>
</div>
<div class="stat-card">
<h6 class="mb-3"><i class="bi bi-eye"></i> What the buyer sees</h6>
<p class="small mb-0">
The buyer's batch page reflects a new shipment status immediately. There is
no notification, and no history of when each stage happened.
</p>
</div>
{% if other_shipments %}
<div class="stat-card mt-3">
<h6 class="mb-3"><i class="bi bi-exclamation-triangle"></i> Other shipments on this batch</h6>
<p class="small mb-2">
A split consignment is legitimate, so this is shown rather than blocked.
Check it is not a duplicate.
</p>
<ul class="small mb-0 ps-3">
{% for s in other_shipments %}
<li>{{ s.get_status_display }}{% if s.container_ref %} &mdash; {{ s.container_ref }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -4,7 +4,10 @@
<div class="page-header d-flex justify-content-between align-items-center">
<h2>Shipments</h2>
<span class="text-secondary">{{ page_obj.paginator.count }} total</span>
<div class="d-flex align-items-center gap-2">
<span class="text-secondary">{{ page_obj.paginator.count }} total</span>
<a href="{% url 'ops:shipment_new' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New shipment</a>
</div>
</div>
<div class="filter-bar">