diff --git a/spp_drims/README.rst b/spp_drims/README.rst
index 35393c23..e81e4138 100644
--- a/spp_drims/README.rst
+++ b/spp_drims/README.rst
@@ -179,6 +179,15 @@ Dependencies
Changelog
=========
+19.0.3.0.3
+~~~~~~~~~~
+
+- feat(drims): confirm a delivery through a popup rather than the
+ dispatch form. Confirm Delivery now collects the receiver, signature,
+ photos and GPS together with what actually arrived per line, and
+ records the delivered quantities against the request so fulfilment
+ reflects what was received rather than what was sent (#1088)
+
19.0.3.0.0
~~~~~~~~~~
diff --git a/spp_drims/__manifest__.py b/spp_drims/__manifest__.py
index e775182a..bbdbf1d2 100644
--- a/spp_drims/__manifest__.py
+++ b/spp_drims/__manifest__.py
@@ -5,7 +5,7 @@
"and distribution tracking. Links to hazard incidents with multi-tier "
"approval workflows and warehouse operations.",
"category": "OpenSPP/Inventory",
- "version": "19.0.3.0.0",
+ "version": "19.0.3.0.3",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -60,6 +60,7 @@
"wizard/allocation_preview_wizard_views.xml",
"wizard/create_return_wizard_views.xml",
"wizard/inspection_wizard_views.xml",
+ "wizard/delivery_confirmation_wizard_views.xml",
# Views
"views/alert_views.xml",
"views/donation_views.xml",
diff --git a/spp_drims/models/stock_picking.py b/spp_drims/models/stock_picking.py
index 703c4403..3b5c3ab7 100644
--- a/spp_drims/models/stock_picking.py
+++ b/spp_drims/models/stock_picking.py
@@ -168,13 +168,65 @@ def action_open_gis_map(self):
def action_confirm_departure(self):
"""Confirm dispatch departure."""
for rec in self:
+ if rec.is_pod_confirmed:
+ raise UserError(
+ _("Dispatch %s is already marked delivered; its departure cannot be re-recorded.") % rec.name
+ )
rec.date_departed = fields.Datetime.now()
+ def action_open_delivery_confirmation(self):
+ """Open the delivery confirmation popup (OP#1088).
+
+ Replaces typing the receiver's details into the form and then pressing a
+ button that refused if they were missing. The wizard collects proof of
+ delivery and what actually arrived per line, then writes both back here.
+ """
+ self.ensure_one()
+ if self.drims_type != "request_dispatch":
+ raise UserError(_("Deliveries can only be confirmed on dispatch pickings."))
+ if not self.date_departed:
+ raise UserError(
+ _("Dispatch %s has not departed yet. Confirm departure before confirming delivery.") % self.name
+ )
+ if self.state != "done":
+ # The wizard's lines come from *done* moves, so on a departed but
+ # unvalidated transfer it would open empty — and confirming an empty
+ # wizard writes the POD block, sets is_pod_confirmed and records no
+ # delivered quantities at all, after which the guard above prevents
+ # ever recording them. Reachable by following the documented flow of
+ # OP#1087, which confirms departure before validating (OP#1088
+ # review).
+ raise UserError(
+ _("Dispatch %s has not been validated yet. Validate the transfer before confirming delivery.")
+ % self.name
+ )
+ if self.is_pod_confirmed:
+ raise UserError(_("Delivery for dispatch %s is already confirmed.") % self.name)
+
+ return {
+ "type": "ir.actions.act_window",
+ "name": _("Confirm Delivery"),
+ "res_model": "spp.drims.delivery.confirmation.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {"default_picking_id": self.id},
+ }
+
def action_confirm_pod(self):
- """Confirm proof of delivery."""
+ """Confirm proof of delivery directly, without the wizard.
+
+ Kept for programmatic callers; the form routes through
+ ``action_open_delivery_confirmation`` instead (OP#1088). The departure
+ check is enforced here too, so no path can record an arrival for goods
+ that never left.
+ """
for rec in self:
if not rec.pod_received_by:
raise UserError(_("Please enter the receiver's name."))
+ if not rec.date_departed:
+ raise UserError(
+ _("Dispatch %s has not departed yet. Confirm departure before confirming delivery.") % rec.name
+ )
rec.is_pod_confirmed = True
rec.date_arrived = fields.Datetime.now()
diff --git a/spp_drims/readme/HISTORY.md b/spp_drims/readme/HISTORY.md
index 41a4b4ea..0d162e0f 100644
--- a/spp_drims/readme/HISTORY.md
+++ b/spp_drims/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.3.0.3
+
+- feat(drims): confirm a delivery through a popup rather than the dispatch form. Confirm Delivery now collects the receiver, signature, photos and GPS together with what actually arrived per line, and records the delivered quantities against the request so fulfilment reflects what was received rather than what was sent (#1088)
+
### 19.0.3.0.0
- feat(drims): allocate stock per source warehouse. The Allocate Stock wizard now auto-splits each requested line across the DRIMS warehouses that hold stock (e.g. 70 → 50 @ WH1 + 20 @ WH2) with editable rows; the split is captured on a new per-warehouse allocation record, shown on the request's Allocations tab and summarised in a "Source Warehouse(s)" column on the Requests list; dispatch creates one picking per source warehouse. The single "Source Warehouse" field on the request has been removed — the warehouse(s) are chosen in the wizard. The allocation wizard distinguishes no-stock, stock-shortfall and deliberate partial-allocation cases with clear messages, and the request line's Fulfillment % tracks allocated ÷ requested so the bar reflects allocation progress (#1079)
diff --git a/spp_drims/security/ir.model.access.csv b/spp_drims/security/ir.model.access.csv
index 632f35dc..b274f9c1 100644
--- a/spp_drims/security/ir.model.access.csv
+++ b/spp_drims/security/ir.model.access.csv
@@ -137,6 +137,14 @@ access_spp_drims_create_return_wizard_coordinator,DRIMS Create Return Wizard Coo
access_spp_drims_create_return_wizard_line_coordinator,DRIMS Create Return Wizard Line Coordinator,model_spp_drims_create_return_wizard_line,group_drims_coordinator_supervisor,1,1,1,0
access_spp_drims_create_return_wizard_field_officer,DRIMS Create Return Wizard Field Officer,model_spp_drims_create_return_wizard,group_drims_field_officer,1,1,1,0
access_spp_drims_create_return_wizard_line_field_officer,DRIMS Create Return Wizard Line Field Officer,model_spp_drims_create_return_wizard_line,group_drims_field_officer,1,1,1,0
+access_spp_drims_delivery_confirmation_wizard_manager,DRIMS Delivery Confirmation Wizard Manager,model_spp_drims_delivery_confirmation_wizard,group_drims_manager,1,1,1,1
+access_spp_drims_delivery_confirmation_wizard_line_manager,DRIMS Delivery Confirmation Wizard Line Manager,model_spp_drims_delivery_confirmation_wizard_line,group_drims_manager,1,1,1,1
+access_spp_drims_delivery_confirmation_wizard_officer,DRIMS Delivery Confirmation Wizard Officer,model_spp_drims_delivery_confirmation_wizard,group_drims_officer,1,1,1,0
+access_spp_drims_delivery_confirmation_wizard_line_officer,DRIMS Delivery Confirmation Wizard Line Officer,model_spp_drims_delivery_confirmation_wizard_line,group_drims_officer,1,1,1,0
+access_spp_drims_delivery_confirmation_wizard_warehouse_staff,DRIMS Delivery Confirmation Wizard Warehouse Staff,model_spp_drims_delivery_confirmation_wizard,group_drims_warehouse_worker,1,1,1,0
+access_spp_drims_delivery_confirmation_wizard_line_warehouse_staff,DRIMS Delivery Confirmation Wizard Line Warehouse Staff,model_spp_drims_delivery_confirmation_wizard_line,group_drims_warehouse_worker,1,1,1,0
+access_spp_drims_delivery_confirmation_wizard_coordinator,DRIMS Delivery Confirmation Wizard Coordinator,model_spp_drims_delivery_confirmation_wizard,group_drims_coordinator_supervisor,1,1,1,0
+access_spp_drims_delivery_confirmation_wizard_line_coordinator,DRIMS Delivery Confirmation Wizard Line Coordinator,model_spp_drims_delivery_confirmation_wizard_line,group_drims_coordinator_supervisor,1,1,1,0
access_spp_drims_allocation_preview_wizard_manager,DRIMS Allocation Preview Wizard Manager,model_spp_drims_allocation_preview_wizard,group_drims_manager,1,1,1,1
access_spp_drims_allocation_preview_wizard_line_manager,DRIMS Allocation Preview Wizard Line Manager,model_spp_drims_allocation_preview_wizard_line,group_drims_manager,1,1,1,1
access_spp_drims_allocation_preview_wizard_officer,DRIMS Allocation Preview Wizard Officer,model_spp_drims_allocation_preview_wizard,group_drims_officer,1,1,1,0
diff --git a/spp_drims/static/description/index.html b/spp_drims/static/description/index.html
index f64fb3c6..bc9ccbf3 100644
--- a/spp_drims/static/description/index.html
+++ b/spp_drims/static/description/index.html
@@ -565,6 +565,16 @@
feat(drims): confirm a delivery through a popup rather than the
+dispatch form. Confirm Delivery now collects the receiver, signature,
+photos and GPS together with what actually arrived per line, and
+records the delivered quantities against the request so fulfilment
+reflects what was received rather than what was sent (#1088)
+
+
+
19.0.3.0.0
feat(drims): allocate stock per source warehouse. The Allocate Stock
@@ -584,7 +594,7 @@
19.0.3.0.0
destination-type selector (#1075)
-
+
19.0.2.0.0
Initial migration to OpenSPP2
diff --git a/spp_drims/tests/__init__.py b/spp_drims/tests/__init__.py
index bb8d0dc9..d9d18378 100644
--- a/spp_drims/tests/__init__.py
+++ b/spp_drims/tests/__init__.py
@@ -5,6 +5,7 @@
from . import test_allocation_preview_wizard
from . import test_approval
from . import test_coordination
+from . import test_delivery_confirmation_wizard
from . import test_donation
from . import test_incident
from . import test_personnel
diff --git a/spp_drims/tests/test_delivery_confirmation_wizard.py b/spp_drims/tests/test_delivery_confirmation_wizard.py
new file mode 100644
index 00000000..97bed768
--- /dev/null
+++ b/spp_drims/tests/test_delivery_confirmation_wizard.py
@@ -0,0 +1,438 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+import base64
+from datetime import date, timedelta
+
+from lxml import etree
+
+from odoo import fields
+from odoo.exceptions import UserError, ValidationError
+from odoo.tests import tagged
+
+from .common import DrimsTestCommon
+
+
+@tagged("post_install", "-at_install")
+class TestDrimsDeliveryConfirmation(DrimsTestCommon):
+ """OP#1088: Confirm Delivery collects proof of delivery in one popup.
+
+ Also the first thing in the module to write
+ ``spp.drims.request.line.quantity_delivered`` — until now nothing did, so a
+ request's ``total_delivered`` and ``fulfillment_pct`` stayed at 0 however
+ much had arrived.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.future_date = date.today() + timedelta(days=30)
+ self.pod_complete = self.vocab_code.search(
+ [
+ ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:pod-statuses"),
+ ("code", "=", "complete"),
+ ],
+ limit=1,
+ )
+ self.pod_partial = self.vocab_code.search(
+ [
+ ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:pod-statuses"),
+ ("code", "=", "partial"),
+ ],
+ limit=1,
+ )
+
+ # ------------------------------------------------------------------
+ # helpers
+ # ------------------------------------------------------------------
+
+ def _delivered_dispatch(self, requested=100, shipped=None):
+ """A validated dispatch that has departed, ready for delivery confirmation."""
+ self.env["stock.quant"].create(
+ {
+ "product_id": self.product.id,
+ "location_id": self.warehouse.lot_stock_id.id,
+ "quantity": requested,
+ }
+ )
+ request = self.env["spp.drims.request"].create(
+ {
+ "incident_id": self.incident.id,
+ "destination_area_id": self.area.id,
+ "date_needed": self.future_date,
+ "line_ids": [
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product.id,
+ "quantity_requested": requested,
+ "uom_id": self.product.uom_id.id,
+ },
+ )
+ ],
+ }
+ )
+ request.action_submit()
+ request.action_approve()
+ # OP#1079: quantity_allocated is a stored compute over per-warehouse
+ # allocation rows, so allocating means recording a row.
+ self.env["spp.drims.request.allocation"].create(
+ {
+ "request_line_id": request.line_ids[0].id,
+ "warehouse_id": self.warehouse.id,
+ "quantity_allocated": requested,
+ }
+ )
+ request.state_id = self.vocab_code.search(
+ [
+ ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:request-states"),
+ ("code", "=", "allocated"),
+ ],
+ limit=1,
+ )
+ request.action_create_dispatch()
+ picking = request.picking_ids
+ picking.write({"beneficiary_count": 300, "beneficiary_area_id": self.area.id})
+ move = picking.move_ids[0]
+ move.quantity = shipped if shipped is not None else requested
+ move.picked = True
+ picking.with_context(skip_backorder=True).button_validate()
+ picking.action_confirm_departure()
+ return request, picking
+
+ def _departed_but_unvalidated_dispatch(self):
+ """The order OP#1087 documents: confirm departure, validate later."""
+ request, picking = self._delivered_dispatch()
+ # Undo the validation the shared fixture performs, keeping departure.
+ picking.state = "assigned"
+ picking.move_ids.write({"state": "assigned"})
+ return request, picking
+
+ def _wizard_for(self, picking, **overrides):
+ vals = {
+ "pod_received_by": "Barangay Captain Reyes",
+ "pod_receiver_title": "Barangay Captain",
+ "pod_status_id": self.pod_complete.id,
+ }
+ vals.update(overrides)
+ return (
+ self.env["spp.drims.delivery.confirmation.wizard"].with_context(default_picking_id=picking.id).create(vals)
+ )
+
+ # ------------------------------------------------------------------
+ # opening the popup
+ # ------------------------------------------------------------------
+
+ def test_button_opens_the_wizard_once_departed(self):
+ _request, picking = self._delivered_dispatch()
+
+ action = picking.action_open_delivery_confirmation()
+
+ self.assertEqual(action["res_model"], "spp.drims.delivery.confirmation.wizard")
+ self.assertEqual(action["target"], "new")
+ self.assertEqual(action["context"]["default_picking_id"], picking.id)
+
+ def test_cannot_confirm_delivery_before_departure(self):
+ """The order is enforced on the model, not only by hiding the button."""
+ _request, picking = self._delivered_dispatch()
+ picking.date_departed = False
+
+ with self.assertRaises(UserError) as cm:
+ picking.action_open_delivery_confirmation()
+ self.assertIn("has not departed", str(cm.exception))
+
+ # The direct API is guarded too, so RPC cannot bypass the ordering.
+ picking.pod_received_by = "Someone"
+ with self.assertRaises(UserError):
+ picking.action_confirm_pod()
+
+ def test_cannot_confirm_delivery_before_the_transfer_is_validated(self):
+ """Departed but not validated is a reachable, unrecoverable trap.
+
+ OP#1087's documented flow confirms departure *before* validating. Taken
+ in that order, the wizard's lines come from done moves and there are
+ none, so confirming would write the POD block, burn the one-shot
+ is_pod_confirmed flag and record no delivered quantities — leaving
+ fulfilment at zero with no way to correct it (OP#1088 review).
+ """
+ _request, picking = self._departed_but_unvalidated_dispatch()
+
+ with self.assertRaises(UserError) as cm:
+ picking.action_open_delivery_confirmation()
+ self.assertIn("has not been validated", str(cm.exception))
+
+ def test_confirming_with_no_lines_is_refused(self):
+ """The same trap reached directly, without going through the button."""
+ _request, picking = self._delivered_dispatch()
+ wizard = self._wizard_for(picking)
+ wizard.line_ids = [(5, 0, 0)]
+
+ with self.assertRaises(UserError) as cm:
+ wizard.action_confirm()
+ self.assertIn("nothing to confirm", str(cm.exception))
+ self.assertFalse(picking.is_pod_confirmed, "the one-shot flag must not be burned")
+
+ def test_delivery_photos_are_filed_against_the_dispatch(self):
+ """They upload against the transient wizard; the evidence belongs here."""
+ _request, picking = self._delivered_dispatch()
+ photo = self.env["ir.attachment"].create(
+ {
+ "name": "pod-photo-1088.png",
+ "datas": base64.b64encode(b"not really a png"),
+ "res_model": "spp.drims.delivery.confirmation.wizard",
+ }
+ )
+
+ self._wizard_for(picking, pod_photo_ids=[(6, 0, photo.ids)]).action_confirm()
+
+ self.assertEqual(photo.res_model, "stock.picking")
+ self.assertEqual(photo.res_id, picking.id)
+
+ def test_cannot_confirm_delivery_twice(self):
+ _request, picking = self._delivered_dispatch()
+ self._wizard_for(picking).action_confirm()
+
+ with self.assertRaises(UserError) as cm:
+ picking.action_open_delivery_confirmation()
+ self.assertIn("already confirmed", str(cm.exception))
+
+ def test_wizard_rejects_non_dispatch_pickings(self):
+ picking = self.env["stock.picking"].create(
+ {
+ "picking_type_id": self.warehouse.out_type_id.id,
+ "location_id": self.warehouse.lot_stock_id.id,
+ "location_dest_id": self.env.ref("stock.stock_location_customers").id,
+ }
+ )
+ with self.assertRaises(UserError):
+ picking.action_open_delivery_confirmation()
+
+ # ------------------------------------------------------------------
+ # what the wizard pre-fills
+ # ------------------------------------------------------------------
+
+ def test_lines_default_to_what_was_dispatched(self):
+ """Lines come from the done moves, defaulting to everything arriving."""
+ request, picking = self._delivered_dispatch(requested=100)
+
+ wizard = self._wizard_for(picking)
+
+ self.assertEqual(len(wizard.line_ids), 1)
+ line = wizard.line_ids
+ self.assertEqual(line.product_id, self.product)
+ self.assertEqual(line.quantity_dispatched, 100)
+ self.assertEqual(line.quantity_delivered, 100)
+ self.assertEqual(line.request_line_id, request.line_ids)
+
+ def test_lines_are_filled_when_picking_comes_in_the_values(self):
+ """Lines must not depend on picking_id arriving via the context.
+
+ ``default_get`` only runs for fields absent from the values, so passing
+ ``picking_id`` in the values — the obvious way from a script or over RPC —
+ used to produce a wizard with no lines that silently recorded no
+ delivered quantities.
+ """
+ request, picking = self._delivered_dispatch(requested=100)
+
+ wizard = self.env["spp.drims.delivery.confirmation.wizard"].create(
+ {
+ "picking_id": picking.id,
+ "pod_received_by": "Direct Caller",
+ "pod_status_id": self.pod_complete.id,
+ }
+ )
+
+ self.assertEqual(len(wizard.line_ids), 1)
+ self.assertEqual(wizard.line_ids.quantity_delivered, 100)
+
+ wizard.action_confirm()
+ self.assertEqual(request.total_delivered, 100)
+
+ # ------------------------------------------------------------------
+ # saving the popup the way the web client does
+ # ------------------------------------------------------------------
+
+ def test_line_derives_its_context_from_the_move(self):
+ """A line saved with only the move and a quantity still knows the rest.
+
+ This is the payload the popup actually sends: the web client drops
+ readonly fields on save, so product, unit, dispatched quantity and
+ request line never came back from the browser. The unit being empty
+ turned the over-delivery check into a bare singleton error, and the
+ empty request line would have sent the delivered quantities nowhere.
+ """
+ request, picking = self._delivered_dispatch(requested=100)
+ move = picking.move_ids
+
+ wizard = self.env["spp.drims.delivery.confirmation.wizard"].create(
+ {
+ "picking_id": picking.id,
+ "pod_received_by": "Barangay Captain Reyes",
+ "pod_status_id": self.pod_complete.id,
+ "line_ids": [(0, 0, {"move_id": move.id, "quantity_delivered": 100})],
+ }
+ )
+
+ line = wizard.line_ids
+ self.assertEqual(len(line), 1)
+ self.assertEqual(line.product_id, self.product)
+ self.assertEqual(line.uom_id, move.product_uom)
+ self.assertEqual(line.quantity_dispatched, 100)
+ self.assertEqual(line.request_line_id, request.line_ids)
+
+ wizard.action_confirm()
+ self.assertEqual(request.total_delivered, 100)
+
+ def test_over_delivery_still_caught_on_a_move_only_line(self):
+ """The quantity guard must survive the move-derived rewrite."""
+ _request, picking = self._delivered_dispatch(requested=100)
+
+ with self.assertRaises(ValidationError):
+ self.env["spp.drims.delivery.confirmation.wizard"].create(
+ {
+ "picking_id": picking.id,
+ "pod_received_by": "Barangay Captain Reyes",
+ "pod_status_id": self.pod_complete.id,
+ "line_ids": [(0, 0, {"move_id": picking.move_ids.id, "quantity_delivered": 150})],
+ }
+ )
+
+ def test_move_id_stays_writable_and_in_the_arch(self):
+ """Guard the mechanism the fix depends on.
+
+ ``move_id`` is the only value that has to survive the browser round
+ trip. If it ever goes readonly, or drops out of the line list, the
+ popup silently returns to saving lines with no context at all.
+ """
+ line_model = self.env["spp.drims.delivery.confirmation.wizard.line"]
+ self.assertFalse(
+ line_model._fields["move_id"].readonly,
+ "move_id must stay writable — readonly fields are not sent back on save",
+ )
+
+ arch = etree.fromstring(self.env.ref("spp_drims.spp_drims_delivery_confirmation_wizard_form").arch)
+ nodes = arch.xpath("//field[@name='line_ids']//field[@name='move_id']")
+ self.assertTrue(nodes, "move_id must be present in the delivered-items list")
+ self.assertNotEqual(nodes[0].get("readonly"), "1", "move_id must not be readonly in the view")
+
+ # ------------------------------------------------------------------
+ # confirming
+ # ------------------------------------------------------------------
+
+ def test_confirm_writes_the_proof_of_delivery(self):
+ _request, picking = self._delivered_dispatch()
+ arrival = fields.Datetime.now()
+
+ self._wizard_for(
+ picking,
+ date_arrived=arrival,
+ pod_receiver_id_number="ID-4471",
+ pod_notes="Handed over at the covered court.",
+ pod_gps_latitude=14.5995,
+ pod_gps_longitude=120.9842,
+ ).action_confirm()
+
+ self.assertTrue(picking.is_pod_confirmed)
+ self.assertEqual(picking.pod_received_by, "Barangay Captain Reyes")
+ self.assertEqual(picking.pod_receiver_title, "Barangay Captain")
+ self.assertEqual(picking.pod_receiver_id_number, "ID-4471")
+ self.assertEqual(picking.pod_status_id, self.pod_complete)
+ self.assertEqual(picking.date_arrived, arrival)
+ self.assertEqual(picking.pod_notes, "Handed over at the covered court.")
+ self.assertAlmostEqual(picking.pod_gps_latitude, 14.5995, places=4)
+ # The GeoPoint compute picks the coordinates up for GIS reporting.
+ self.assertTrue(picking.pod_gps_point)
+
+ def test_confirm_records_delivered_quantities_on_the_request(self):
+ """The point of the lines table: fulfillment stops reading 0."""
+ request, picking = self._delivered_dispatch(requested=100)
+ self.assertEqual(request.total_delivered, 0)
+ self.assertEqual(request.fulfillment_pct, 0)
+
+ self._wizard_for(picking).action_confirm()
+
+ self.assertEqual(request.line_ids[0].quantity_delivered, 100)
+ self.assertEqual(request.total_delivered, 100)
+ self.assertEqual(request.fulfillment_pct, 100)
+
+ def test_short_delivery_records_only_what_arrived(self):
+ request, picking = self._delivered_dispatch(requested=100)
+ wizard = self._wizard_for(picking, pod_status_id=self.pod_partial.id)
+ wizard.line_ids.quantity_delivered = 80
+ wizard.discrepancy_notes = "20 units water-damaged in transit."
+
+ wizard.action_confirm()
+
+ self.assertEqual(request.total_delivered, 80)
+ self.assertEqual(request.fulfillment_pct, 80)
+ self.assertEqual(picking.discrepancy_notes, "20 units water-damaged in transit.")
+
+ def test_delivered_quantities_accumulate_across_dispatches(self):
+ """A request filled by two shipments must total, not overwrite.
+
+ Shipping 60 of 100 leaves a backorder for the balance, which is the
+ request's second dispatch. Both confirmations contribute.
+ """
+ request, first = self._delivered_dispatch(requested=100, shipped=60)
+ self._wizard_for(first).action_confirm()
+ self.assertEqual(request.total_delivered, 60)
+
+ second = self.env["stock.picking"].search([("backorder_id", "=", first.id)])
+ self.assertEqual(len(second), 1, "partial validation should leave a backorder")
+ self.assertEqual(second.drims_request_id, request)
+
+ # Set the beneficiary fields explicitly rather than relying on whatever
+ # the backorder inherited from its parent.
+ second.write({"beneficiary_count": 100, "beneficiary_area_id": self.area.id})
+ second.move_ids.quantity = 40
+ second.move_ids.picked = True
+ second.with_context(skip_backorder=True).button_validate()
+ second.action_confirm_departure()
+ self._wizard_for(second).action_confirm()
+
+ self.assertEqual(second.state, "done")
+ self.assertEqual(request.total_delivered, 100)
+ self.assertEqual(request.fulfillment_pct, 100)
+
+ # ------------------------------------------------------------------
+ # validation
+ # ------------------------------------------------------------------
+
+ def test_cannot_deliver_more_than_was_dispatched(self):
+ _request, picking = self._delivered_dispatch(requested=100)
+ wizard = self._wizard_for(picking)
+
+ with self.assertRaises(ValidationError) as cm:
+ wizard.line_ids.quantity_delivered = 120
+ self.assertIn("more", str(cm.exception))
+
+ def test_cannot_deliver_a_negative_quantity(self):
+ _request, picking = self._delivered_dispatch()
+ wizard = self._wizard_for(picking)
+
+ with self.assertRaises(ValidationError):
+ wizard.line_ids.quantity_delivered = -5
+
+ def test_arrival_cannot_precede_departure(self):
+ _request, picking = self._delivered_dispatch()
+ wizard = self._wizard_for(picking, date_arrived=picking.date_departed - timedelta(hours=2))
+
+ with self.assertRaises(UserError) as cm:
+ wizard.action_confirm()
+ self.assertIn("cannot be before", str(cm.exception))
+
+ def test_out_of_range_gps_is_rejected(self):
+ """Out-of-range coordinates must raise, not be silently dropped.
+
+ ``_compute_pod_gps_point`` logs a warning and stores nothing, which would
+ lose the delivery location without telling anyone.
+ """
+ _request, picking = self._delivered_dispatch()
+
+ with self.assertRaises(ValidationError):
+ self._wizard_for(picking, pod_gps_latitude=95.0)
+
+ def test_departure_cannot_be_re_recorded_after_delivery(self):
+ _request, picking = self._delivered_dispatch()
+ self._wizard_for(picking).action_confirm()
+
+ with self.assertRaises(UserError):
+ picking.action_confirm_departure()
diff --git a/spp_drims/tests/test_stock.py b/spp_drims/tests/test_stock.py
index 2ee174ca..f3e05ee5 100644
--- a/spp_drims/tests/test_stock.py
+++ b/spp_drims/tests/test_stock.py
@@ -63,6 +63,8 @@ def test_pod_confirmation(self):
"pod_received_by": "Test Receiver",
}
)
+ # OP#1088: delivery cannot be confirmed before departure is recorded.
+ picking.action_confirm_departure()
picking.action_confirm_pod()
self.assertTrue(picking.is_pod_confirmed)
self.assertTrue(picking.date_arrived)
diff --git a/spp_drims/views/stock_picking_views.xml b/spp_drims/views/stock_picking_views.xml
index 5d1540ec..35d41b8e 100644
--- a/spp_drims/views/stock_picking_views.xml
+++ b/spp_drims/views/stock_picking_views.xml
@@ -190,52 +190,118 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
+ Confirm departure first
+
+
+
+ Departed but not yet delivered. Use
+ Confirm Delivery above to record the handover.
+
-
-
-
-
-
+
+
+
+
+
-
+
-
+
@@ -243,6 +309,7 @@
name="pod_photo_ids"
widget="many2many_binary"
nolabel="1"
+ readonly="is_pod_confirmed"
/>
@@ -251,11 +318,13 @@
name="pod_notes"
nolabel="1"
placeholder="Additional delivery notes..."
+ readonly="is_pod_confirmed"
/>
diff --git a/spp_drims/wizard/__init__.py b/spp_drims/wizard/__init__.py
index 514fd529..afbf0678 100644
--- a/spp_drims/wizard/__init__.py
+++ b/spp_drims/wizard/__init__.py
@@ -9,3 +9,4 @@
from . import request_from_template_wizard
from . import create_return_wizard
from . import inspection_wizard
+from . import delivery_confirmation_wizard
diff --git a/spp_drims/wizard/delivery_confirmation_wizard.py b/spp_drims/wizard/delivery_confirmation_wizard.py
new file mode 100644
index 00000000..b8b68e37
--- /dev/null
+++ b/spp_drims/wizard/delivery_confirmation_wizard.py
@@ -0,0 +1,267 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""DRIMS Delivery Confirmation Wizard (OP#1088).
+
+Collects proof of delivery in one popup off the dispatch's Confirm Delivery
+button, instead of expecting the officer to have typed the receiver's details
+into the form beforehand and then press a button that refuses if they had not.
+
+It also records what was actually delivered per line. Nothing in the module
+wrote ``spp.drims.request.line.quantity_delivered`` before this, so a request's
+``total_delivered`` and ``fulfillment_pct`` sat at 0 however much had arrived,
+and ``spp.drims.alert`` kept reporting the full requested quantity as still
+needed. Confirming delivery is the point at which that number is known.
+"""
+
+import logging
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import float_compare
+
+_logger = logging.getLogger(__name__)
+
+
+class DeliveryConfirmationWizard(models.TransientModel):
+ _name = "spp.drims.delivery.confirmation.wizard"
+ _description = "Confirm DRIMS Delivery"
+
+ picking_id = fields.Many2one(
+ "stock.picking",
+ string="Dispatch",
+ required=True,
+ readonly=True,
+ domain="[('drims_type', '=', 'request_dispatch')]",
+ )
+ request_id = fields.Many2one(
+ related="picking_id.drims_request_id",
+ string="Request",
+ )
+ date_departed = fields.Datetime(
+ related="picking_id.date_departed",
+ string="Departed At",
+ )
+ date_arrived = fields.Datetime(
+ string="Arrived At",
+ required=True,
+ default=fields.Datetime.now,
+ help="When the consignment reached the destination",
+ )
+
+ # Receiver
+ pod_received_by = fields.Char(
+ string="Received By",
+ required=True,
+ help="Name of the person who took receipt of the consignment",
+ )
+ pod_receiver_title = fields.Char(string="Receiver Title")
+ pod_receiver_id_number = fields.Char(string="Receiver ID Number")
+ pod_status_id = fields.Many2one(
+ "spp.vocabulary.code",
+ string="Delivery Status",
+ required=True,
+ domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:drims:pod-statuses')]",
+ help="Complete, partial, damaged or not received",
+ )
+
+ # Evidence
+ pod_signature = fields.Binary(string="Receiver Signature")
+ pod_photo_ids = fields.Many2many("ir.attachment", string="Delivery Photos")
+ pod_gps_latitude = fields.Float(string="GPS Latitude", digits=(10, 6))
+ pod_gps_longitude = fields.Float(string="GPS Longitude", digits=(10, 6))
+
+ # Narrative
+ pod_notes = fields.Text(string="Delivery Notes")
+ discrepancy_notes = fields.Text(
+ string="Discrepancy Notes",
+ help="Anything short, damaged or otherwise not as dispatched",
+ )
+
+ line_ids = fields.One2many(
+ "spp.drims.delivery.confirmation.wizard.line",
+ "wizard_id",
+ string="Delivered Items",
+ )
+
+ @api.constrains("pod_gps_latitude", "pod_gps_longitude")
+ def _check_gps_coordinates(self):
+ """Reject coordinates outside the valid range.
+
+ ``stock.picking._compute_pod_gps_point`` silently drops out-of-range
+ values with a log warning, which would lose the delivery location
+ without telling the officer.
+ """
+ for wizard in self:
+ if wizard.pod_gps_latitude and not -90 <= wizard.pod_gps_latitude <= 90:
+ raise ValidationError(_("GPS latitude must be between -90 and 90."))
+ if wizard.pod_gps_longitude and not -180 <= wizard.pod_gps_longitude <= 180:
+ raise ValidationError(_("GPS longitude must be between -180 and 180."))
+
+ @api.model
+ def _prepare_line_commands(self, picking):
+ """Build the delivered-items rows from what the dispatch actually moved."""
+ commands = []
+ for move in picking.move_ids.filtered(lambda m: m.state == "done"):
+ commands.append(
+ (
+ 0,
+ 0,
+ {
+ # The product, unit, dispatched quantity and request line
+ # all follow from the move, so the move is the only thing
+ # worth writing down.
+ "move_id": move.id,
+ # Default to "all of it arrived" — the common case, and the
+ # officer only has to touch the lines that fell short.
+ "quantity_delivered": move.quantity,
+ },
+ )
+ )
+ return commands
+
+ @api.model
+ def default_get(self, fields_list):
+ """Pre-populate the lines so the popup opens with quantities ready to edit."""
+ res = super().default_get(fields_list)
+ picking_id = res.get("picking_id") or self.env.context.get("default_picking_id")
+ if not picking_id or "line_ids" not in fields_list:
+ return res
+ res["line_ids"] = self._prepare_line_commands(self.env["stock.picking"].browse(picking_id))
+ return res
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Fill the lines for any wizard created without them.
+
+ ``default_get`` only runs for fields *absent* from the values, so a caller
+ that passes ``picking_id`` in the values rather than in the context — the
+ obvious way to do it from a script or over RPC — would otherwise get a
+ wizard with no lines, silently record no delivered quantities, and leave
+ fulfillment reading 0. Populate here as well so every path behaves.
+ """
+ for vals in vals_list:
+ if vals.get("picking_id") and not vals.get("line_ids"):
+ picking = self.env["stock.picking"].browse(vals["picking_id"])
+ vals["line_ids"] = self._prepare_line_commands(picking)
+ return super().create(vals_list)
+
+ def action_confirm(self):
+ """Write the proof of delivery onto the dispatch and its request."""
+ self.ensure_one()
+ picking = self.picking_id
+
+ if not picking.date_departed:
+ raise UserError(
+ _("Dispatch %s has not departed yet. Confirm departure before confirming delivery.") % picking.name
+ )
+ if picking.is_pod_confirmed:
+ raise UserError(_("Delivery for dispatch %s is already confirmed.") % picking.name)
+ if self.date_arrived < picking.date_departed:
+ raise UserError(_("The arrival time cannot be before the departure time."))
+ if not self.line_ids:
+ # Belt and braces with the state check on the opener: confirming with
+ # nothing to record burns the one-shot is_pod_confirmed flag and
+ # leaves fulfillment at zero with no way back (OP#1088 review).
+ raise UserError(_("There is nothing to confirm for dispatch %s: it has no delivered lines.") % picking.name)
+
+ picking.write(
+ {
+ "date_arrived": self.date_arrived,
+ "pod_received_by": self.pod_received_by,
+ "pod_receiver_title": self.pod_receiver_title,
+ "pod_receiver_id_number": self.pod_receiver_id_number,
+ "pod_status_id": self.pod_status_id.id,
+ "pod_signature": self.pod_signature,
+ "pod_photo_ids": [(6, 0, self.pod_photo_ids.ids)],
+ "pod_gps_latitude": self.pod_gps_latitude,
+ "pod_gps_longitude": self.pod_gps_longitude,
+ "pod_notes": self.pod_notes,
+ "discrepancy_notes": self.discrepancy_notes,
+ "is_pod_confirmed": True,
+ }
+ )
+
+ if self.pod_photo_ids:
+ # Uploaded through the wizard form, so the attachments were filed
+ # against this transient model. The evidence belongs to the dispatch:
+ # re-point it so the provenance is right and access follows the
+ # picking rather than transient-model semantics (OP#1088 review).
+ self.pod_photo_ids.write({"res_model": picking._name, "res_id": picking.id})
+
+ self._record_delivered_quantities()
+
+ _logger.info(
+ "DRIMS delivery confirmed for %s by %s (%s)",
+ picking.name,
+ self.pod_received_by,
+ self.pod_status_id.code,
+ )
+ return {"type": "ir.actions.act_window_close"}
+
+ def _record_delivered_quantities(self):
+ """Add the delivered quantities to their request lines.
+
+ Accumulated rather than assigned: a request can be filled by several
+ dispatches, so each confirmation contributes its share.
+ """
+ self.ensure_one()
+ for line in self.line_ids.filtered(lambda line: line.request_line_id):
+ request_line = line.request_line_id
+ request_line.quantity_delivered = request_line.quantity_delivered + line.quantity_delivered
+
+
+class DeliveryConfirmationWizardLine(models.TransientModel):
+ _name = "spp.drims.delivery.confirmation.wizard.line"
+ _description = "Confirm DRIMS Delivery Line"
+
+ wizard_id = fields.Many2one(
+ "spp.drims.delivery.confirmation.wizard",
+ required=True,
+ ondelete="cascade",
+ )
+ # Everything except the quantity is derived from the move rather than held
+ # as its own value (OP#1088 round 2). These were plain readonly fields, and
+ # the web client does not send readonly fields back when it saves, so a
+ # confirmation submitted from the popup arrived with nothing but
+ # ``quantity_delivered``: the unit was empty, which made ``uom_id.compare``
+ # raise a bare singleton error, and the request line was empty too, so the
+ # delivered quantities this ticket exists to record would have gone
+ # nowhere. Only ``move_id`` now has to survive the round trip.
+ move_id = fields.Many2one(
+ "stock.move",
+ string="Stock Move",
+ required=True,
+ ondelete="cascade",
+ )
+ request_line_id = fields.Many2one(
+ related="move_id.drims_request_line_id",
+ string="Request Line",
+ )
+ product_id = fields.Many2one(related="move_id.product_id", string="Product")
+ uom_id = fields.Many2one(related="move_id.product_uom", string="Unit")
+ quantity_dispatched = fields.Float(related="move_id.quantity", string="Dispatched")
+ quantity_delivered = fields.Float(string="Delivered")
+
+ @api.constrains("quantity_delivered")
+ def _check_quantity_delivered(self):
+ """Delivered has to be between nothing and what was dispatched."""
+ for line in self:
+ if line.quantity_delivered < 0:
+ raise ValidationError(_("Delivered quantity cannot be negative."))
+ # Compare in the line's own unit where there is one, but never let a
+ # missing unit turn an over-delivery check into a singleton error.
+ if line.uom_id:
+ over_delivered = line.uom_id.compare(line.quantity_delivered, line.quantity_dispatched) > 0
+ else:
+ over_delivered = (
+ float_compare(line.quantity_delivered, line.quantity_dispatched, precision_digits=2) > 0
+ )
+ if over_delivered:
+ raise ValidationError(
+ _(
+ "Cannot deliver more %(product)s than was dispatched "
+ "(%(delivered)s delivered vs %(dispatched)s dispatched).",
+ product=line.product_id.display_name,
+ delivered=line.quantity_delivered,
+ dispatched=line.quantity_dispatched,
+ )
+ )
diff --git a/spp_drims/wizard/delivery_confirmation_wizard_views.xml b/spp_drims/wizard/delivery_confirmation_wizard_views.xml
new file mode 100644
index 00000000..134998b1
--- /dev/null
+++ b/spp_drims/wizard/delivery_confirmation_wizard_views.xml
@@ -0,0 +1,142 @@
+
+
+
+
+ spp.drims.delivery.confirmation.wizard.form
+ spp.drims.delivery.confirmation.wizard
+
+
+
+
+
+
+ Confirm Delivery
+ spp.drims.delivery.confirmation.wizard
+ form
+ new
+
+