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:
parent
b376c30300
commit
5e201b2035
6 changed files with 341 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
89
templates/ops/shipment_form.html
Normal file
89
templates/ops/shipment_form.html
Normal 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 …
|
||||
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 %} — {{ s.container_ref }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue