From bb39f614bd11ff05dbf49c20395e7fbdfd81267c Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Mon, 3 Aug 2026 15:07:59 +0800 Subject: [PATCH 1/2] fix(spp_drims): stop backorder dispatches bypassing the DRIMS request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validating a dispatch short of its demand and choosing "Create Backorder" left the request reading as fully dispatched, told no one, and attributed the parent shipment's per-shipment facts to goods still in the warehouse. Odoo builds a backorder with picking.copy(), so every field left at the default copy=True was inherited. Mark the per-shipment facts copy=False: beneficiary count, departure/arrival, the pod_* block, transport and driver details, discrepancy notes and drims_return_id. This also stops the Duplicate action producing a dispatch that claims a delivery, and it makes the beneficiary guard in button_validate() fire on the backorder instead of being pre-satisfied by the inherited value — which had let one 100-unit distribution to 500 people report 1000 beneficiaries served on spp.hazard.incident.drims_beneficiaries_served. Announce the backorder on the request: an internal note plus a to-do activity for the coordinators of the destination area, resolved by mirroring rule_request_coordinator_scope so it reaches exactly those permitted to see the request. Reopen the request at Ready for Dispatch while a backorder is pending, and re-advance to Dispatched once nothing is outstanding. Because action_create_dispatch counts a quantity as dispatched when it is committed to a picking rather than when it ships, that running total is now rebuilt from the moves that still stand whenever moves are validated or cancelled. A single reconciliation covers a cancelled backorder, a cancelled dispatch, and declining Create Backorder — the last of which cancels no move at all, it just drops the excess demand, so a cancellation hook alone would have missed it. Odoo already carries drims_request_id and drims_request_line_id onto the backorder and its split move, so the request link and per-line attribution were correct and are covered by a regression test. OP#1087 --- spp_drims/models/request.py | 148 ++++++++++ spp_drims/models/request_line.py | 36 +++ spp_drims/models/stock_move.py | 24 ++ spp_drims/models/stock_picking.py | 60 ++++- spp_drims/tests/__init__.py | 1 + spp_drims/tests/test_dispatch_backorder.py | 297 +++++++++++++++++++++ 6 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 spp_drims/tests/test_dispatch_backorder.py diff --git a/spp_drims/models/request.py b/spp_drims/models/request.py index 05725f586..75d0c56e9 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 @@ -752,3 +754,149 @@ 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) + return ( + self.env["res.users"] + .sudo() + .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 + ) + body = Markup("

    %s

    ") % ( + _( + "Dispatch %(parent)s was validated short of its demand. Backorder " + "%(backorder)s holds the remaining balance and has not been dispatched yet.", + parent=backorder.backorder_id.name or _("(unknown)"), + 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() + 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. + """ + for rec in self.sudo(): + 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``. + """ + for rec in self.sudo(): + 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 e17f4cba1..bd85146f0 100644 --- a/spp_drims/models/request_line.py +++ b/spp_drims/models/request_line.py @@ -95,6 +95,42 @@ 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. + + ``quantity`` and ``product_uom_qty`` are both expressed in the move's + ``product_uom``, which the dispatch sets to this line'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() + for line in self.sudo(): + dispatched = 0.0 + for move in Move.search( + [ + ("drims_request_line_id", "=", line.id), + ("state", "!=", "cancel"), + ] + ): + dispatched += move.quantity if move.state == "done" else move.product_uom_qty + line.quantity_dispatched = dispatched + 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 e5dffbaf6..ed97e1670 100644 --- a/spp_drims/models/stock_move.py +++ b/spp_drims/models/stock_move.py @@ -17,6 +17,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 703c4403f..3016f81e5 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): @@ -297,6 +311,7 @@ def button_validate(self): # Get incidents before validation changes state incident_ids = list(set(p.incident_id.id for p in self if p.incident_id and p.drims_type == "request_dispatch")) + requests = self.filtered(lambda p: p.drims_type == "request_dispatch").drims_request_id result = super().button_validate() @@ -304,8 +319,27 @@ def button_validate(self): if incident_ids: self._invalidate_drims_kpi_cache(incident_ids) + # ``result`` is only ``True`` once the transfer is actually done; a dict + # means Odoo returned a wizard (e.g. "Create Backorder?") and nothing has + # been validated yet, so there is no state change to settle. + if result is True and 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/tests/__init__.py b/spp_drims/tests/__init__.py index bb8d0dc9a..0cd72487f 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 000000000..8d97a7a96 --- /dev/null +++ b/spp_drims/tests/test_dispatch_backorder.py @@ -0,0 +1,297 @@ +# 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 _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, + "source_warehouse_id": self.warehouse.id, + "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() + request.line_ids[0].quantity_allocated = 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_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) + 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 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") + + 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") + + # ------------------------------------------------------------------ + # 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) From a73a31bdaeee68f23f4dda452365e6897e59d102 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 11 Aug 2026 10:53:28 +0800 Subject: [PATCH 2/2] test(spp_drims): follow the per-warehouse allocation model OP#1079 removed spp.drims.request.source_warehouse_id and made the request line's quantity_allocated a stored compute over allocation rows, so these fixtures no longer built a request at all. --- spp_drims/tests/test_dispatch_backorder.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/spp_drims/tests/test_dispatch_backorder.py b/spp_drims/tests/test_dispatch_backorder.py index 8d97a7a96..cb7359eac 100644 --- a/spp_drims/tests/test_dispatch_backorder.py +++ b/spp_drims/tests/test_dispatch_backorder.py @@ -35,6 +35,21 @@ def _stock_up(self, 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( @@ -42,7 +57,6 @@ def _dispatch_for(self, requested=100, allocated=100): "incident_id": self.incident.id, "destination_area_id": self.area.id, "date_needed": self.future_date, - "source_warehouse_id": self.warehouse.id, "line_ids": [ ( 0, @@ -58,7 +72,7 @@ def _dispatch_for(self, requested=100, allocated=100): ) request.action_submit() request.action_approve() - request.line_ids[0].quantity_allocated = allocated + self._allocate(request.line_ids[0], allocated) request.state_id = request._get_state_by_code("allocated") request.action_create_dispatch() picking = request.picking_ids