diff --git a/spp_drims/README.rst b/spp_drims/README.rst
index 35393c23..2c37a3d2 100644
--- a/spp_drims/README.rst
+++ b/spp_drims/README.rst
@@ -179,6 +179,17 @@ Dependencies
Changelog
=========
+19.0.3.0.1
+~~~~~~~~~~
+
+- fix(drims): a dispatch validated short no longer leaves the request
+ looking fully dispatched. The backorder is announced on the request
+ with a to-do for the coordinators, the request reopens as Ready for
+ Dispatch so the remaining balance can be dispatched again, and the
+ dispatched totals are rebuilt on the allocation rows. Applies however
+ the transfer is validated — the web client, the barcode flow or the
+ API (#1087)
+
19.0.3.0.0
~~~~~~~~~~
diff --git a/spp_drims/__manifest__.py b/spp_drims/__manifest__.py
index e775182a..5f36b0e9 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.1",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_drims/models/request.py b/spp_drims/models/request.py
index 5f455f3e..b20c3ee1 100644
--- a/spp_drims/models/request.py
+++ b/spp_drims/models/request.py
@@ -2,6 +2,8 @@
import logging
from datetime import timedelta
+from markupsafe import Markup
+
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
@@ -609,13 +611,7 @@ def action_resubmit(self):
def _set_state_by_code(self, code):
"""Set the request state to the vocab code, if it exists."""
self.ensure_one()
- state = self.env["spp.vocabulary.code"].search(
- [
- ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:request-states"),
- ("code", "=", code),
- ],
- limit=1,
- )
+ state = self._get_state_by_code(code)
if state:
self.state_id = state
return state
@@ -878,3 +874,161 @@ def action_view_pickings(self):
"view_mode": "list,form",
"domain": [("drims_request_id", "=", self.id)],
}
+
+ # ------------------------------------------------------------------
+ # Dispatch backorders (OP#1087)
+ # ------------------------------------------------------------------
+
+ def _get_state_by_code(self, code):
+ """Return the request-state vocabulary code record matching ``code``."""
+ return self.env["spp.vocabulary.code"].search(
+ [
+ (
+ "vocabulary_id.namespace_uri",
+ "=",
+ "urn:openspp:vocab:drims:request-states",
+ ),
+ ("code", "=", code),
+ ],
+ limit=1,
+ )
+
+ def _get_drims_coordinators(self):
+ """Coordinators accountable for this request's destination area.
+
+ Mirrors ``rule_request_coordinator_scope`` (security/rules.xml), which
+ grants a coordinator access when ``destination_area_id`` is a descendant
+ of one of their ``drims_area_ids``. The reverse lookup therefore matches
+ coordinators assigned to the destination area itself or to any of its
+ ancestors. Runs sudo because the warehouse officer triggering this has no
+ read access to other users' area assignments.
+ """
+ self.ensure_one()
+ group = self.env.ref(
+ "spp_drims.group_drims_coordinator_supervisor",
+ raise_if_not_found=False,
+ )
+ area = self.destination_area_id
+ if not group or not area:
+ return self.env["res.users"]
+ # ``parent_path`` is a "1/4/9/" style chain that already includes self.
+ area_ids = {int(part) for part in (area.parent_path or "").split("/") if part}
+ area_ids.add(area.id)
+ # Resolving who to notify about a backorder. Reads res.users only to
+ # collect recipient ids for a message; the acting warehouse officer has
+ # no reason to be able to search users, and nothing about them is
+ # exposed beyond being messaged.
+ users = self.env["res.users"].sudo() # nosemgrep: odoo-sudo-on-sensitive-models,odoo-sudo-without-context
+ return users.search(
+ [
+ ("all_group_ids", "in", group.id),
+ ("drims_area_ids", "in", list(area_ids)),
+ ]
+ )
+
+ def _notify_dispatch_backorder(self, backorder):
+ """Announce a dispatch backorder on the request and assign a follow-up.
+
+ Odoo creates backorders silently, so without this the coordinator gets no
+ signal that part of an approved request never left the warehouse. Posted
+ as an internal note addressed to the coordinators (rather than a customer
+ message) so they are notified without mailing external partners, plus a
+ to-do activity so the outstanding balance is owned by somebody.
+ """
+ self.ensure_one()
+ items = Markup("").join(
+ Markup("
%s: %s %s")
+ % (
+ move.product_id.display_name,
+ move.product_uom_qty,
+ move.product_uom.name,
+ )
+ for move in backorder.move_ids
+ )
+ # Named explicitly because the note is posted through sudo, which makes
+ # OdooBot its author. In a humanitarian-accountability trail, "who
+ # shipped short" is part of the record (OP#1087 review).
+ body = Markup("%s
") % (
+ _(
+ "Dispatch %(parent)s was validated short of its demand by %(user)s. Backorder "
+ "%(backorder)s holds the remaining balance and has not been dispatched yet.",
+ parent=backorder.backorder_id.name or _("(unknown)"),
+ user=self.env.user.display_name,
+ backorder=backorder.name,
+ ),
+ items,
+ )
+ coordinators = self._get_drims_coordinators()
+ self.message_post(
+ body=body,
+ partner_ids=coordinators.partner_id.ids,
+ subtype_xmlid="mail.mt_note",
+ )
+ for coordinator in coordinators:
+ self.activity_schedule(
+ "mail.mail_activity_data_todo",
+ summary=_("Release dispatch backorder %s", backorder.name),
+ user_id=coordinator.id,
+ )
+
+ def _on_dispatch_backorder_created(self, backorder):
+ """React to Odoo splitting off a backorder from one of this request's dispatches.
+
+ The backordered quantity never left the warehouse, so a request that had
+ already advanced to ``dispatched`` reopens at ``allocated`` (Ready for
+ Dispatch) until the backorder is validated too. Runs sudo because the
+ warehouse officer validating the short dispatch may sit outside the
+ request's area scope, and this is system bookkeeping rather than a user
+ edit.
+ """
+ self.ensure_one()
+ request = self.sudo() # nosemgrep: odoo-sudo-without-context
+ request._notify_dispatch_backorder(backorder)
+ if request.state == "dispatched":
+ allocated_state = request._get_state_by_code("allocated")
+ if allocated_state:
+ request.state_id = allocated_state
+
+ def _reopen_if_not_fully_dispatched(self):
+ """Drop back to ``allocated`` when the dispatched balance no longer covers
+ the request.
+
+ Called after a dispatch quantity is released (see
+ ``stock.move._action_cancel``): a request that reads ``dispatched`` while
+ part of it was cancelled rather than shipped has to become actionable
+ again.
+
+ Runs sudo for the same reason as the caller: the officer releasing the
+ quantity may sit outside the request's area scope, and moving the
+ request back to actionable is bookkeeping rather than a user edit.
+ """
+ for rec in self.sudo(): # nosemgrep: odoo-sudo-without-context
+ if rec.state != "dispatched" or not rec.line_ids:
+ continue
+ if all(line.quantity_dispatched >= line.quantity_requested for line in rec.line_ids):
+ continue
+ allocated_state = rec._get_state_by_code("allocated")
+ if allocated_state:
+ rec.state_id = allocated_state
+
+ def _sync_state_after_dispatch_done(self):
+ """Re-advance to ``dispatched`` once no dispatch of this request is pending.
+
+ Counterpart to ``_on_dispatch_backorder_created``: when the outstanding
+ backorder is finally validated, the request returns to ``dispatched``.
+
+ Runs sudo for the same reason as its counterpart: the validating officer
+ need not have write access to the request under the area record rules.
+ """
+ for rec in self.sudo(): # nosemgrep: odoo-sudo-without-context
+ if rec.state != "allocated":
+ continue
+ pending = rec.picking_ids.filtered(
+ lambda p: p.drims_type == "request_dispatch" and p.state not in ("done", "cancel")
+ )
+ if pending:
+ continue
+ if rec.line_ids and all(line.quantity_dispatched >= line.quantity_requested for line in rec.line_ids):
+ dispatched_state = rec._get_state_by_code("dispatched")
+ if dispatched_state:
+ rec.state_id = dispatched_state
diff --git a/spp_drims/models/request_line.py b/spp_drims/models/request_line.py
index b465d3d6..b03873b6 100644
--- a/spp_drims/models/request_line.py
+++ b/spp_drims/models/request_line.py
@@ -153,6 +153,55 @@ def _compute_fulfillment(self):
else:
line.fulfillment_pct = 0.0
+ def _reconcile_quantity_dispatched(self):
+ """Recompute ``quantity_dispatched`` from the dispatch moves that still stand.
+
+ ``quantity_dispatched`` counts quantity committed to a dispatch picking,
+ which ``spp.drims.request.action_create_dispatch`` increments when the
+ picking is created rather than when it ships. Once moves are validated or
+ cancelled that running total can drift from reality, so it is rebuilt
+ here (OP#1087):
+
+ - a cancelled move never shipped and no longer counts at all;
+ - a done move counts what actually moved, not what was demanded, which is
+ what makes declining "Create Backorder" release the balance;
+ - a move still in progress keeps counting its demand, so a pending
+ backorder stays committed to the request.
+
+ Rebuilt onto the **per-warehouse allocation rows** rather than onto the
+ line (OP#1079). The line's ``quantity_dispatched`` is a stored compute
+ summing ``allocation_ids.quantity_dispatched``, so assigning to it
+ directly no longer registers at all — the write is silently discarded
+ and the released quantity never comes back, which is what broke the
+ backorder flow after the per-warehouse allocation model landed.
+
+ Splitting by allocation is unambiguous because every dispatch move
+ carries the allocation it draws from: ``action_create_dispatch`` creates
+ one move per allocation and stamps ``drims_allocation_id`` on it, and a
+ backorder copies that link along with the rest of the move.
+
+ ``quantity`` and ``product_uom_qty`` are both expressed in the move's
+ ``product_uom``, which the dispatch sets from the allocation's
+ ``uom_id``, so the two are directly comparable.
+
+ Runs sudo: warehouse staff validating or cancelling a dispatch need not
+ have write access to the request under the area record rules, and this is
+ system bookkeeping rather than a user edit.
+ """
+ Move = self.env["stock.move"].sudo() # nosemgrep: odoo-sudo-without-context
+ for allocation in self.sudo().mapped("allocation_ids"): # nosemgrep: odoo-sudo-without-context
+ dispatched = 0.0
+ for move in Move.search(
+ [
+ ("drims_allocation_id", "=", allocation.id),
+ ("state", "!=", "cancel"),
+ ]
+ ):
+ dispatched += move.quantity if move.state == "done" else move.product_uom_qty
+ allocation.quantity_dispatched = dispatched
+ # nosemgrep: odoo-sudo-without-context
+ self.sudo().request_id._reopen_if_not_fully_dispatched()
+
@api.onchange("product_id")
def _onchange_product_id(self):
if self.product_id:
diff --git a/spp_drims/models/stock_move.py b/spp_drims/models/stock_move.py
index e0c21ec8..a3ac75ac 100644
--- a/spp_drims/models/stock_move.py
+++ b/spp_drims/models/stock_move.py
@@ -22,6 +22,30 @@ class StockMove(models.Model):
help="Link to the donation line this move receives",
)
+ def _action_done(self, cancel_backorder=False):
+ """Reconcile the request's dispatch counter once moves are validated.
+
+ Needed because Odoo has more than one way to drop undelivered demand:
+ declining "Create Backorder" leaves the move done at the picked quantity
+ without cancelling anything, so the shortfall is invisible to a
+ cancellation hook (OP#1087).
+ """
+ lines = self.drims_request_line_id
+ result = super()._action_done(cancel_backorder=cancel_backorder)
+ lines.exists()._reconcile_quantity_dispatched()
+ return result
+
+ def _action_cancel(self):
+ """Reconcile the request's dispatch counter when moves are cancelled.
+
+ Covers a cancelled backorder and a cancelled dispatch: neither quantity
+ ever shipped, so neither may keep counting as dispatched (OP#1087).
+ """
+ lines = self.drims_request_line_id
+ result = super()._action_cancel()
+ lines.exists()._reconcile_quantity_dispatched()
+ return result
+
@api.model
def _prepare_merge_moves_distinct_fields(self):
"""Keep DRIMS donation/request lines distinct when Odoo merges moves.
diff --git a/spp_drims/models/stock_picking.py b/spp_drims/models/stock_picking.py
index 703c4403..4bd30b0f 100644
--- a/spp_drims/models/stock_picking.py
+++ b/spp_drims/models/stock_picking.py
@@ -38,6 +38,7 @@ class StockPicking(models.Model):
"spp.drims.return",
string="DRIMS Return",
index=True,
+ copy=False,
)
incident_id = fields.Many2one(
"spp.hazard.incident",
@@ -53,6 +54,7 @@ class StockPicking(models.Model):
)
beneficiary_count = fields.Integer(
string="Estimated Beneficiaries Reached",
+ copy=False,
help="Estimated number of beneficiaries who received items (exact counts often unknown in emergencies)",
)
distribution_type_id = fields.Many2one(
@@ -84,49 +86,61 @@ class StockPicking(models.Model):
)
# Transport
+ # Every field below records what happened on one physical shipment, so none
+ # of them may be carried onto a copy of the picking. Odoo builds a backorder
+ # with ``picking.copy()`` (``stock.picking._create_backorder_picking``), so
+ # without ``copy=False`` a backorder inherits the parent's departure
+ # timestamp, driver, POD and beneficiary count — claiming a delivery for
+ # goods still sitting in the warehouse, and double-counting the parent's
+ # beneficiaries in ``spp.hazard.incident.drims_beneficiaries_served``
+ # (OP#1087). The same applies to the Duplicate action.
transport_mode_id = fields.Many2one(
"spp.vocabulary.code",
string="Transport Mode",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:drims:transport-modes')]",
+ copy=False,
)
- vehicle_registration = fields.Char(string="Vehicle Registration")
- driver_name = fields.Char(string="Driver Name")
- driver_phone = fields.Char(string="Driver Phone")
+ vehicle_registration = fields.Char(string="Vehicle Registration", copy=False)
+ driver_name = fields.Char(string="Driver Name", copy=False)
+ driver_phone = fields.Char(string="Driver Phone", copy=False)
# Proof of Delivery (POD)
pod_status_id = fields.Many2one(
"spp.vocabulary.code",
string="POD Status",
domain="[('vocabulary_id.namespace_uri', '=', 'urn:openspp:vocab:drims:pod-statuses')]",
+ copy=False,
)
is_pod_confirmed = fields.Boolean(
string="POD Confirmed",
default=False,
+ copy=False,
)
- pod_received_by = fields.Char(string="Received By")
- pod_receiver_title = fields.Char(string="Receiver Title")
- pod_receiver_id_number = fields.Char(string="Receiver ID Number")
- pod_signature = fields.Binary(string="Signature")
+ pod_received_by = fields.Char(string="Received By", copy=False)
+ pod_receiver_title = fields.Char(string="Receiver Title", copy=False)
+ pod_receiver_id_number = fields.Char(string="Receiver ID Number", copy=False)
+ pod_signature = fields.Binary(string="Signature", copy=False)
pod_photo_ids = fields.Many2many(
"ir.attachment",
string="Delivery Photos",
+ copy=False,
)
- pod_gps_latitude = fields.Float(string="GPS Latitude", digits=(10, 6))
- pod_gps_longitude = fields.Float(string="GPS Longitude", digits=(10, 6))
+ pod_gps_latitude = fields.Float(string="GPS Latitude", digits=(10, 6), copy=False)
+ pod_gps_longitude = fields.Float(string="GPS Longitude", digits=(10, 6), copy=False)
pod_gps_point = fields.GeoPointField(
string="POD GPS Point",
compute="_compute_pod_gps_point",
store=True,
help="Computed geographic point from POD GPS coordinates for GIS mapping",
)
- pod_notes = fields.Text(string="POD Notes")
+ pod_notes = fields.Text(string="POD Notes", copy=False)
# Dates
- date_departed = fields.Datetime(string="Departed At")
- date_arrived = fields.Datetime(string="Arrived At")
+ date_departed = fields.Datetime(string="Departed At", copy=False)
+ date_arrived = fields.Datetime(string="Arrived At", copy=False)
# Discrepancy
- discrepancy_notes = fields.Text(string="Discrepancy Notes")
+ discrepancy_notes = fields.Text(string="Discrepancy Notes", copy=False)
@api.model_create_multi
def create(self, vals_list):
@@ -306,6 +320,40 @@ def button_validate(self):
return result
+ def _action_done(self):
+ """Settle the request's state once a dispatch is really done.
+
+ This used to hang off button_validate, which only covers the web
+ client's Validate button: a backorder released through the API, the
+ barcode flow or a direct _action_done reconciled its quantities through
+ the move hook but never re-advanced the request, leaving it at
+ "allocated" with everything already shipped (OP#1087 review).
+
+ _action_done is the point every path goes through, and calling it after
+ super() means Odoo has already split off any backorder — which is what
+ _sync_state_after_dispatch_done inspects before advancing.
+ """
+ result = super()._action_done()
+
+ requests = self.filtered(lambda p: p.drims_type == "request_dispatch").drims_request_id
+ if requests:
+ requests._sync_state_after_dispatch_done()
+
+ return result
+
+ def _create_backorder(self, backorder_moves=None):
+ """Surface DRIMS dispatch backorders on their request (OP#1087).
+
+ Odoo creates the backorder picking silently, so on its own a partially
+ validated dispatch leaves the coordinator with no notification and the
+ request still reading as fully dispatched.
+ """
+ backorders = super()._create_backorder(backorder_moves=backorder_moves)
+ for backorder in backorders:
+ if backorder.drims_type == "request_dispatch" and backorder.drims_request_id:
+ backorder.drims_request_id._on_dispatch_backorder_created(backorder)
+ return backorders
+
def _invalidate_drims_kpi_cache(self, incident_ids):
"""Invalidate DRIMS KPI cache for distributed and stock values.
diff --git a/spp_drims/readme/HISTORY.md b/spp_drims/readme/HISTORY.md
index 41a4b4ea..8b23f40e 100644
--- a/spp_drims/readme/HISTORY.md
+++ b/spp_drims/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.3.0.1
+
+- fix(drims): a dispatch validated short no longer leaves the request looking fully dispatched. The backorder is announced on the request with a to-do for the coordinators, the request reopens as Ready for Dispatch so the remaining balance can be dispatched again, and the dispatched totals are rebuilt on the allocation rows. Applies however the transfer is validated — the web client, the barcode flow or the API (#1087)
+
### 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/static/description/index.html b/spp_drims/static/description/index.html
index f64fb3c6..26845b56 100644
--- a/spp_drims/static/description/index.html
+++ b/spp_drims/static/description/index.html
@@ -565,6 +565,18 @@
+
19.0.3.0.1
+
+- fix(drims): a dispatch validated short no longer leaves the request
+looking fully dispatched. The backorder is announced on the request
+with a to-do for the coordinators, the request reopens as Ready for
+Dispatch so the remaining balance can be dispatched again, and the
+dispatched totals are rebuilt on the allocation rows. Applies however
+the transfer is validated — the web client, the barcode flow or the
+API (#1087)
+
+
+
19.0.3.0.0
- feat(drims): allocate stock per source warehouse. The Allocate Stock
@@ -584,7 +596,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..0cd72487 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_dispatch_backorder
from . import test_donation
from . import test_incident
from . import test_personnel
diff --git a/spp_drims/tests/test_dispatch_backorder.py b/spp_drims/tests/test_dispatch_backorder.py
new file mode 100644
index 00000000..df73f0be
--- /dev/null
+++ b/spp_drims/tests/test_dispatch_backorder.py
@@ -0,0 +1,365 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+from datetime import date, timedelta
+
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+from .common import DrimsTestCommon
+
+
+@tagged("post_install", "-at_install")
+class TestDrimsDispatchBackorder(DrimsTestCommon):
+ """OP#1087: a dispatch validated short must not bypass the DRIMS request.
+
+ Odoo builds a backorder with ``picking.copy()``, which used to carry the
+ parent's per-shipment facts (beneficiary count, departure, driver, POD) onto
+ goods still sitting in the warehouse, leave the request reading as fully
+ ``dispatched``, and tell nobody.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.future_date = date.today() + timedelta(days=30)
+
+ # ------------------------------------------------------------------
+ # helpers
+ # ------------------------------------------------------------------
+
+ def _stock_up(self, quantity):
+ """Put ``quantity`` of the test product into the DRIMS warehouse."""
+ self.env["stock.quant"].create(
+ {
+ "product_id": self.product.id,
+ "location_id": self.warehouse.lot_stock_id.id,
+ "quantity": quantity,
+ }
+ )
+
+ def _allocate(self, line, quantity, warehouse=None):
+ """Record a per-warehouse allocation for ``line``.
+
+ OP#1079 replaced the writable ``quantity_allocated`` on the request line
+ with a stored compute over ``allocation_ids``, so allocation has to be
+ expressed as a row against a warehouse.
+ """
+ return self.env["spp.drims.request.allocation"].create(
+ {
+ "request_line_id": line.id,
+ "warehouse_id": (warehouse or self.warehouse).id,
+ "quantity_allocated": quantity,
+ }
+ )
+
+ def _dispatch_for(self, requested=100, allocated=100):
+ """Return an allocated request plus its confirmed dispatch picking."""
+ 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()
+ self._allocate(request.line_ids[0], allocated)
+ request.state_id = request._get_state_by_code("allocated")
+ request.action_create_dispatch()
+ picking = request.picking_ids
+ self.assertEqual(len(picking), 1)
+ # Fill what button_validate() demands of a DRIMS dispatch, and record a
+ # departure so we can prove it does not leak onto the backorder.
+ picking.write(
+ {
+ "beneficiary_count": 500,
+ "beneficiary_area_id": self.area.id,
+ "driver_name": "Test Driver",
+ }
+ )
+ picking.action_confirm_departure()
+ return request, picking
+
+ def _validate_short(self, picking, quantity):
+ """Validate ``picking`` for ``quantity`` only, choosing Create Backorder."""
+ picking.move_ids.write({"quantity": quantity, "picked": True})
+ action = picking.button_validate()
+ self.assertIsInstance(action, dict, "expected the Create Backorder wizard")
+ self.assertEqual(action["res_model"], "stock.backorder.confirmation")
+ wizard = (
+ self.env["stock.backorder.confirmation"]
+ .with_context(**action["context"])
+ .create(
+ {
+ "pick_ids": [(6, 0, picking.ids)],
+ "backorder_confirmation_line_ids": [(0, 0, {"to_backorder": True, "picking_id": picking.id})],
+ }
+ )
+ )
+ wizard.with_context(**action["context"]).process()
+ backorder = self.env["stock.picking"].search([("backorder_id", "=", picking.id)])
+ self.assertEqual(len(backorder), 1)
+ return backorder
+
+ # ------------------------------------------------------------------
+ # per-shipment facts must not be inherited
+ # ------------------------------------------------------------------
+
+ def test_backorder_does_not_inherit_beneficiary_count(self):
+ """The parent's beneficiary count must not be attributed to the backorder.
+
+ ``spp.hazard.incident.drims_beneficiaries_served`` sums beneficiary_count
+ over every done dispatch, so an inherited value double-counts the same
+ people once the backorder is validated too.
+ """
+ self._stock_up(100)
+ _request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+
+ self.assertEqual(picking.beneficiary_count, 500)
+ self.assertFalse(backorder.beneficiary_count)
+
+ def test_backorder_does_not_inherit_departure_or_driver(self):
+ """A backorder has not departed, whatever the parent recorded."""
+ self._stock_up(100)
+ _request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+
+ self.assertTrue(picking.date_departed)
+ self.assertFalse(backorder.date_departed)
+ self.assertFalse(backorder.date_arrived)
+ self.assertFalse(backorder.driver_name)
+ self.assertFalse(backorder.is_pod_confirmed)
+
+ def test_backorder_validation_requires_its_own_beneficiary_count(self):
+ """The beneficiary guard must fire on the backorder, not be pre-satisfied."""
+ self._stock_up(100)
+ _request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+
+ backorder.move_ids.write({"quantity": 10, "picked": True})
+ with self.assertRaises(UserError) as cm:
+ backorder.button_validate()
+ self.assertIn("beneficiaries served", str(cm.exception))
+
+ def test_backorder_keeps_request_link_and_gets_own_waybill(self):
+ """Identity that *should* carry, carries; the waybill is still unique."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+
+ self.assertEqual(backorder.drims_request_id, request)
+ self.assertEqual(backorder.drims_type, "request_dispatch")
+ self.assertEqual(backorder.incident_id, self.incident)
+ self.assertEqual(request.picking_count, 2)
+ self.assertTrue(backorder.waybill_number)
+ self.assertNotEqual(backorder.waybill_number, picking.waybill_number)
+ # Per-line attribution survives the move split.
+ self.assertEqual(backorder.move_ids.drims_request_line_id, request.line_ids)
+
+ # ------------------------------------------------------------------
+ # request state must account for the outstanding backorder
+ # ------------------------------------------------------------------
+
+ def test_backorder_reopens_request_from_dispatched(self):
+ """The request must not read as dispatched while a backorder is pending."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ self.assertEqual(request.state, "dispatched")
+
+ backorder = self._validate_short(picking, 90)
+
+ self.assertEqual(request.state, "allocated")
+ self.assertEqual(backorder.state, "assigned")
+
+ def test_validating_the_backorder_returns_request_to_dispatched(self):
+ """Once the balance ships, the request advances again."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+ self.assertEqual(request.state, "allocated")
+
+ backorder.write({"beneficiary_count": 40, "beneficiary_area_id": self.area.id})
+ backorder.move_ids.write({"quantity": 10, "picked": True})
+ backorder.button_validate()
+
+ self.assertEqual(backorder.state, "done")
+ self.assertEqual(request.state, "dispatched")
+
+ def test_backorder_validated_outside_the_web_client_still_advances(self):
+ """The re-advance hangs off _action_done, not the Validate button.
+
+ A backorder released through the API, the barcode flow or a direct
+ _action_done reconciles its quantities through the move hook; when the
+ state sync hung off button_validate, none of those paths re-advanced the
+ request, leaving it at "allocated" with everything already shipped
+ (OP#1087 review).
+ """
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+ self.assertEqual(request.state, "allocated")
+
+ backorder.write({"beneficiary_count": 40, "beneficiary_area_id": self.area.id})
+ backorder.move_ids.write({"quantity": 10, "picked": True})
+ # Deliberately not button_validate(): this is the path a non-UI caller
+ # takes into the same transfer.
+ backorder._action_done()
+
+ self.assertEqual(backorder.state, "done")
+ self.assertEqual(request.state, "dispatched")
+
+ def test_the_backorder_note_names_who_shipped_short(self):
+ """The note is posted through sudo, so OdooBot authors it.
+
+ Without naming the acting user in the body, the audit trail records that
+ a dispatch went short but not who validated it (OP#1087 review).
+ """
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ self._validate_short(picking, 90)
+
+ notes = request.message_ids.filtered(lambda m: "short of its demand" in (m.body or ""))
+ self.assertTrue(notes, "the short dispatch should be announced on the request")
+ self.assertIn(self.env.user.display_name, notes[0].body)
+
+ def test_incident_beneficiaries_are_not_double_counted(self):
+ """The whole point: 100 units to 500 people stays 500, not 1000."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+
+ backorder.write({"beneficiary_count": 40, "beneficiary_area_id": self.area.id})
+ backorder.move_ids.write({"quantity": 10, "picked": True})
+ backorder.button_validate()
+
+ self.incident.invalidate_recordset(["drims_beneficiaries_served"])
+ # 500 recorded on the parent plus the 40 the officer entered for the
+ # balance — not the parent's 500 counted twice.
+ self.assertEqual(self.incident.drims_beneficiaries_served, 540)
+ self.assertEqual(request.state, "dispatched")
+
+ # ------------------------------------------------------------------
+ # a cancelled balance must be released, not left counted as dispatched
+ # ------------------------------------------------------------------
+
+ def test_cancelling_the_backorder_releases_the_quantity(self):
+ """A cancelled backorder must leave the request dispatchable again."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ backorder = self._validate_short(picking, 90)
+ self.assertEqual(request.line_ids[0].quantity_dispatched, 100)
+
+ backorder.action_cancel()
+
+ self.assertEqual(backorder.state, "cancel")
+ self.assertEqual(request.state, "allocated")
+ # The 10 units never shipped, so they are available to dispatch again.
+ self.assertEqual(request.line_ids[0].quantity_dispatched, 90)
+ self._assert_released(request, dispatched=90, remaining=10)
+ request.action_create_dispatch()
+ new_dispatch = request.picking_ids - picking - backorder
+ self.assertEqual(len(new_dispatch), 1)
+ self.assertEqual(new_dispatch.move_ids.product_uom_qty, 10)
+
+ def _assert_released(self, request, dispatched, remaining):
+ """The release has to land on the allocation rows, not just the line.
+
+ The line's quantity_dispatched is a stored compute over those rows
+ (OP#1079). Writing it directly persists until something retriggers the
+ compute, so a test that only checks the line can pass while the
+ allocation underneath is still wrong — and it is the allocation that
+ decides how much stock is free to allocate again.
+ """
+ allocations = request.line_ids.allocation_ids
+ self.assertTrue(allocations, "the request line should have allocation rows")
+ self.assertEqual(sum(allocations.mapped("quantity_dispatched")), dispatched)
+ self.assertEqual(sum(allocations.mapped("quantity_remaining")), remaining)
+
+ def test_declining_the_backorder_releases_the_quantity(self):
+ """Answering "No" to Create Backorder cancels the balance, not ships it."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ picking.move_ids.write({"quantity": 90, "picked": True})
+ action = picking.button_validate()
+ wizard = (
+ self.env["stock.backorder.confirmation"]
+ .with_context(**action["context"])
+ .create({"pick_ids": [(6, 0, picking.ids)]})
+ )
+ wizard.with_context(**action["context"]).process_cancel_backorder()
+
+ self.assertEqual(picking.state, "done")
+ self.assertFalse(self.env["stock.picking"].search([("backorder_id", "=", picking.id)]))
+ # Only 90 shipped, so the request must not read as fully dispatched.
+ self.assertEqual(request.line_ids[0].quantity_dispatched, 90)
+ self.assertEqual(request.state, "allocated")
+ self._assert_released(request, dispatched=90, remaining=10)
+
+ def test_cancelling_the_whole_dispatch_releases_everything(self):
+ """Cancelling an unvalidated dispatch returns the full quantity."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ self.assertEqual(request.line_ids[0].quantity_dispatched, 100)
+
+ picking.action_cancel()
+
+ self.assertEqual(picking.state, "cancel")
+ self.assertEqual(request.line_ids[0].quantity_dispatched, 0)
+ self.assertEqual(request.state, "allocated")
+ self._assert_released(request, dispatched=0, remaining=100)
+
+ # ------------------------------------------------------------------
+ # coordinator visibility
+ # ------------------------------------------------------------------
+
+ def test_backorder_notifies_the_area_coordinator(self):
+ """A coordinator for the destination area gets a note and a to-do."""
+ coordinator = self.env["res.users"].create(
+ {
+ "name": "Area Coordinator",
+ "login": "op1087_coordinator",
+ "group_ids": [(4, self.env.ref("spp_drims.group_drims_coordinator_supervisor").id)],
+ "drims_area_ids": [(6, 0, self.area.ids)],
+ }
+ )
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ messages_before = len(request.message_ids)
+
+ backorder = self._validate_short(picking, 90)
+
+ self.assertGreater(len(request.message_ids), messages_before)
+ note = request.message_ids[0]
+ self.assertIn(backorder.name, note.body)
+ self.assertIn(coordinator.partner_id, note.partner_ids)
+
+ activity = self.env["mail.activity"].search(
+ [
+ ("res_model", "=", "spp.drims.request"),
+ ("res_id", "=", request.id),
+ ("user_id", "=", coordinator.id),
+ ]
+ )
+ self.assertEqual(len(activity), 1)
+ self.assertIn(backorder.name, activity.summary)
+
+ def test_backorder_without_any_coordinator_still_logs(self):
+ """No coordinator configured must not break validation."""
+ self._stock_up(100)
+ request, picking = self._dispatch_for()
+ messages_before = len(request.message_ids)
+
+ backorder = self._validate_short(picking, 90)
+
+ self.assertGreater(len(request.message_ids), messages_before)
+ self.assertIn(backorder.name, request.message_ids[0].body)