diff --git a/apps/ops/forms.py b/apps/ops/forms.py index eed8ada..8ac1acc 100644 --- a/apps/ops/forms.py +++ b/apps/ops/forms.py @@ -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 diff --git a/apps/ops/urls.py b/apps/ops/urls.py index ef8474e..c955b49 100644 --- a/apps/ops/urls.py +++ b/apps/ops/urls.py @@ -16,7 +16,9 @@ urlpatterns = [ path('qc/', views.QCListView.as_view(), name='qc_list'), path('qc//', 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//', views.ShipmentDetailView.as_view(), name='shipment_detail'), + path('shipments//edit/', views.ShipmentUpdateView.as_view(), name='shipment_update'), path('capa/', views.CAPAListView.as_view(), name='capa_list'), path('capa//', views.CAPADetailView.as_view(), name='capa_detail'), path('documents/', views.DocumentListView.as_view(), name='document_list'), diff --git a/apps/ops/views.py b/apps/ops/views.py index ab05545..9938cad 100644 --- a/apps/ops/views.py +++ b/apps/ops/views.py @@ -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' diff --git a/templates/ops/shipment_detail.html b/templates/ops/shipment_detail.html index 72c9110..5aaecb0 100644 --- a/templates/ops/shipment_detail.html +++ b/templates/ops/shipment_detail.html @@ -4,15 +4,23 @@ +{% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} +{% endif %} +
- + + diff --git a/templates/ops/shipment_form.html b/templates/ops/shipment_form.html new file mode 100644 index 0000000..6b6656d --- /dev/null +++ b/templates/ops/shipment_form.html @@ -0,0 +1,89 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}{{ page_title }} - IXG Operations{% endblock %} +{% block content %} + + + +
+
+
+
+ {% csrf_token %} + + {% if form.non_field_errors %} + {% for e in form.non_field_errors %}
{{ e }}
{% endfor %} + {% endif %} + + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for e in field.errors %}
{{ e }}
{% endfor %} +
+ {% endfor %} + + + Cancel + +
+
+ +
+
+
What a shipment is
+

+ 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. +

+

+ The record holds the current status, not a history of it. +

+
+ +
+
Two separate statuses
+

+ The batch has its own journey status (Harvested … + Delivered), moved on the batch screen and shown next to the batch in the + dropdown above. The shipment status is the one below, and + it moves freely between its seven values. +

+

+ They are independent: setting a shipment status here does not move the + batch, and the batch screen does not move the shipment. +

+
+ +
+
What the buyer sees
+

+ The buyer's batch page reflects a new shipment status immediately. There is + no notification, and no history of when each stage happened. +

+
+ + {% if other_shipments %} +
+
Other shipments on this batch
+

+ A split consignment is legitimate, so this is shown rather than blocked. + Check it is not a duplicate. +

+
    + {% for s in other_shipments %} +
  • {{ s.get_status_display }}{% if s.container_ref %} — {{ s.container_ref }}{% endif %}
  • + {% endfor %} +
+
+ {% endif %} +
+
+ +{% endblock %} diff --git a/templates/ops/shipment_list.html b/templates/ops/shipment_list.html index d7373e6..33f961a 100644 --- a/templates/ops/shipment_list.html +++ b/templates/ops/shipment_list.html @@ -4,7 +4,10 @@
Batch{{ shipment.batch.batch_reference }}
Status{{ shipment.get_status_display }}
Batch status{{ shipment.batch.get_status_display }}
Shipment status{{ shipment.get_status_display }}
Origin Port{{ shipment.origin_port }}
Destination Port{{ shipment.destination_port }}
Vessel Name{{ shipment.vessel_name|default:'-' }}