Skip to content
Merged
9 changes: 9 additions & 0 deletions spp_drims/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ Dependencies
Changelog
=========

19.0.4.0.3
~~~~~~~~~~

- fix(drims): show only the states a dispatch can reach on its status
bar. A request dispatch is confirmed the moment it is created, so
Draft never applies to it; the shared status bar keeps Draft for every
other outgoing transfer. Waiting is hidden as a future step but still
shows when a dispatch is actually in it (#1086)

19.0.4.0.2
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_drims/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.4.0.2",
"version": "19.0.4.0.3",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
4 changes: 4 additions & 0 deletions spp_drims/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.4.0.3

- fix(drims): show only the states a dispatch can reach on its status bar. A request dispatch is confirmed the moment it is created, so Draft never applies to it; the shared status bar keeps Draft for every other outgoing transfer. Waiting is hidden as a future step but still shows when a dispatch is actually in it (#1086)

### 19.0.4.0.2

- 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)
Expand Down
24 changes: 17 additions & 7 deletions spp_drims/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,16 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.4.0.3</h1>
<ul class="simple">
<li>fix(drims): show only the states a dispatch can reach on its status
bar. A request dispatch is confirmed the moment it is created, so
Draft never applies to it; the shared status bar keeps Draft for every
other outgoing transfer. Waiting is hidden as a future step but still
shows when a dispatch is actually in it (#1086)</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.4.0.2</h1>
<ul class="simple">
<li>feat(drims): confirm a delivery through a popup rather than the
Expand All @@ -574,7 +584,7 @@ <h1>19.0.4.0.2</h1>
reflects what was received rather than what was sent (#1088)</li>
</ul>
</div>
<div class="section" id="section-2">
<div class="section" id="section-3">
<h1>19.0.4.0.1</h1>
<ul class="simple">
<li>fix(drims): only let a dispatch ship what its request approved.
Expand All @@ -584,7 +594,7 @@ <h1>19.0.4.0.1</h1>
and delete affordances hidden to match (#1057)</li>
</ul>
</div>
<div class="section" id="section-3">
<div class="section" id="section-4">
<h1>19.0.4.0.0</h1>
<ul class="simple">
<li>feat(drims): Donations review — creation, receipt, inspection and
Expand All @@ -604,7 +614,7 @@ <h1>19.0.4.0.0</h1>
but no longer readable through the ORM or shown in any view (#1076)</li>
</ul>
</div>
<div class="section" id="section-4">
<div class="section" id="section-5">
<h1>19.0.3.1.0</h1>
<ul class="simple">
<li>feat(drims): Incident Management review — incidents are entered as a
Expand All @@ -621,7 +631,7 @@ <h1>19.0.3.1.0</h1>
refresh cron skips (#1100)</li>
</ul>
</div>
<div class="section" id="section-5">
<div class="section" id="section-6">
<h1>19.0.3.0.4</h1>
<ul class="simple">
<li>feat(drims): rework the dispatch page and correct the waybill.
Expand All @@ -641,7 +651,7 @@ <h1>19.0.3.0.4</h1>
barcode (#1151)</li>
</ul>
</div>
<div class="section" id="section-6">
<div class="section" id="section-7">
<h1>19.0.3.0.1</h1>
<ul class="simple">
<li>fix(drims): a dispatch validated short no longer leaves the request
Expand All @@ -653,7 +663,7 @@ <h1>19.0.3.0.1</h1>
API (#1087)</li>
</ul>
</div>
<div class="section" id="section-7">
<div class="section" id="section-8">
<h1>19.0.3.0.0</h1>
<ul class="simple">
<li>feat(drims): allocate stock per source warehouse. The Allocate Stock
Expand All @@ -673,7 +683,7 @@ <h1>19.0.3.0.0</h1>
destination-type selector (#1075)</li>
</ul>
</div>
<div class="section" id="section-8">
<div class="section" id="section-9">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
Expand Down
1 change: 1 addition & 0 deletions spp_drims/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from . import test_allocation_preview_wizard
from . import test_approval
from . import test_coordination
from . import test_dispatch_statusbar
from . import test_delivery_confirmation_wizard
from . import test_dispatch_line_lock
from . import test_dispatch_page
Expand Down
197 changes: 197 additions & 0 deletions spp_drims/tests/test_dispatch_statusbar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from datetime import date, timedelta

from lxml import etree

from odoo.tests import tagged
from odoo.tools.safe_eval import safe_eval

from .common import DrimsTestCommon


@tagged("post_install", "-at_install")
class TestDrimsDispatchStatusbar(DrimsTestCommon):
"""OP#1086: a request dispatch should not advertise states it never sits in.

Core renders two statusbars for stock.picking, split on picking_type_code. A
dispatch is outgoing, so it picks up the non-incoming one
(draft,confirmed,assigned,done). spp_drims narrows that one to exclude
dispatches and adds a dispatch-only bar, rather than editing the shared
statusbar_visible — which would drop Draft from every non-incoming transfer.
"""

def setUp(self):
super().setUp()
self.future_date = date.today() + timedelta(days=30)

# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------

def _statusbars(self):
"""The state statusbar fields in the combined stock.picking form arch."""
view = self.env["stock.picking"].get_view(self.env.ref("stock.view_picking_form").id, "form")
tree = etree.fromstring(view["arch"])
return tree.xpath("//header/field[@name='state'][@widget='statusbar']")

def _stock_up(self, quantity):
self.env["stock.quant"].create(
{
"product_id": self.product.id,
"location_id": self.warehouse.lot_stock_id.id,
"quantity": quantity,
}
)

def _allocated_request(self, requested=100):
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()
return request

# ------------------------------------------------------------------
# the statusbar arch
# ------------------------------------------------------------------

def test_dispatch_statusbar_shows_only_ready_and_done(self):
"""A dispatch-only statusbar exists, listing just Ready and Done."""
bars = [b for b in self._statusbars() if b.get("statusbar_visible") == "assigned,done"]
self.assertEqual(len(bars), 1, "expected exactly one dispatch statusbar")
condition = bars[0].get("invisible")
self.assertIn("drims_type != 'request_dispatch'", condition)
self.assertIn("picking_type_code == 'incoming'", condition)

def test_other_picking_types_keep_the_full_statusbar(self):
"""AC: no regression for receipts, returns or internal transfers.

Core's two bars must survive untouched apart from the added exclusion, so
anything that is not a request dispatch still shows Draft and Waiting.
"""
by_visible = {b.get("statusbar_visible"): b for b in self._statusbars()}

# Incoming bar: completely untouched.
self.assertIn("draft,assigned,done", by_visible)
self.assertEqual(by_visible["draft,assigned,done"].get("invisible"), "picking_type_code != 'incoming'")

# Non-incoming bar: still lists Draft and Waiting, now skipped only for dispatches.
self.assertIn("draft,confirmed,assigned,done", by_visible)
non_incoming = by_visible["draft,confirmed,assigned,done"].get("invisible")
self.assertIn("drims_type == 'request_dispatch'", non_incoming)

def _visible_statusbars(self, **record):
"""statusbar_visible of every bar whose ``invisible`` is falsy for ``record``.

The attributes are ordinary Python expressions over field values, so
evaluate them rather than pattern-matching the strings.
"""
visible = []
for bar in self._statusbars():
if not safe_eval(bar.get("invisible", "False"), dict(record)):
visible.append(bar.get("statusbar_visible"))
return visible

def test_exactly_one_statusbar_applies_per_picking(self):
"""The three bars must be mutually exclusive, or the form renders two.

Also pins the actual outcome per picking kind, which is the AC.
"""
self.assertEqual(
self._visible_statusbars(picking_type_code="outgoing", drims_type="request_dispatch"),
["assigned,done"],
"a request dispatch should show only Ready and Done",
)
self.assertEqual(
self._visible_statusbars(picking_type_code="outgoing", drims_type=False),
["draft,confirmed,assigned,done"],
"a plain delivery keeps the full bar",
)
self.assertEqual(
self._visible_statusbars(picking_type_code="incoming", drims_type="donation_receipt"),
["draft,assigned,done"],
"a DRIMS donation receipt keeps core's incoming bar",
)
self.assertEqual(
self._visible_statusbars(picking_type_code="internal", drims_type="internal_transfer"),
["draft,confirmed,assigned,done"],
"a DRIMS internal transfer keeps the full bar",
)

# ------------------------------------------------------------------
# the premise behind hiding those states
# ------------------------------------------------------------------

def test_dispatch_is_confirmed_on_creation_never_draft(self):
"""Why Draft is safe to hide: action_create_dispatch confirms immediately."""
self._stock_up(100)
request = self._allocated_request()
request.action_allocate()
request.state_id = self.env["spp.vocabulary.code"].search(
[
("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:request-states"),
("code", "=", "allocated"),
],
limit=1,
)
request.action_create_dispatch()

self.assertNotEqual(request.picking_ids.state, "draft")
self.assertIn(request.picking_ids.state, ("confirmed", "assigned"))

def test_waiting_state_is_reachable_for_a_dispatch(self):
"""Waiting is NOT unreachable, which is why it is hidden only as a
*future* step rather than removed.

DRIMS allocation records per-warehouse rows against the request line; it
creates no Odoo reservation. Since OP#1079 it will not promise the same
stock to two requests, but it still cannot guarantee the stock is there
when the dispatch is finally created — an inventory adjustment, a
transfer, or any other movement can empty the shelf in between. A
dispatch with nothing to reserve lands in ``confirmed``, which the UI
labels Waiting.

The statusbar widget always renders the current value even when it is
excluded from ``statusbar_visible`` (``getAllItems`` keeps any value
equal to the current one), so such a dispatch still shows Waiting to
warehouse staff. Hiding it as a future step costs them nothing.
"""
self._stock_up(100)
allocated_state = self.env["spp.vocabulary.code"].search(
[
("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:request-states"),
("code", "=", "allocated"),
],
limit=1,
)
request = self._allocated_request()
request.action_allocate()
self.assertEqual(request.line_ids[0].quantity_allocated, 100)

# The shelf empties between allocating and dispatching. Allocation
# reserved nothing, so nothing was holding it.
self.env["stock.quant"]._update_available_quantity(self.product, self.warehouse.lot_stock_id, -100)

request.state_id = allocated_state
request.action_create_dispatch()

self.assertEqual(
request.picking_ids.state,
"confirmed",
"a dispatch with nothing left to reserve should sit in Waiting",
)
40 changes: 40 additions & 0 deletions spp_drims/views/stock_picking_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,46 @@
<attribute name="invisible">state != 'done' or drims_type_id</attribute>
</xpath>

<!--
OP#1086: a request dispatch is confirmed the moment
action_create_dispatch creates it, so it never sits in Draft.
Core renders two statusbars for stock.picking, split on
picking_type_code; a dispatch is outgoing, so it picks up the
non-incoming one with draft,confirmed,assigned,done. Narrow that
one to exclude dispatches and add a dispatch-only bar, rather
than editing the shared statusbar_visible, which would drop
Draft from every non-incoming transfer in the database.

Note this hides Waiting (state=confirmed) as a *future* step
only. Waiting IS reachable for a dispatch: DRIMS allocation just
writes quantity_allocated on the request line and reserves
nothing, so two requests can allocate the same units and the
second dispatch finds nothing to reserve. The statusbar widget
always renders the current value even when it is excluded
(getAllItems filters on `value === currentValue ||
visibleSelection.includes(value)`), so such a dispatch still
shows Waiting and stays visible to warehouse staff.
-->
<xpath
expr="//header/field[@name='state'][@statusbar_visible='draft,confirmed,assigned,done']"
position="attributes"
>
<attribute
name="invisible"
>picking_type_code == 'incoming' or drims_type == 'request_dispatch'</attribute>
</xpath>
<xpath
expr="//header/field[@name='state'][@statusbar_visible='draft,confirmed,assigned,done']"
position="after"
>
<field
name="state"
widget="statusbar"
invisible="picking_type_code == 'incoming' or drims_type != 'request_dispatch'"
statusbar_visible="assigned,done"
/>
</xpath>

<!--
OP#1150 round 2: a request dispatch is generated from its
request — action_create_dispatch sets the operation type, the
Expand Down
Loading