Skip to content

fix(spp_drims): stop backorder dispatches bypassing the DRIMS request - #390

Merged
emjay0921 merged 7 commits into
19.0from
fix/1087-drims-backorder-visibility
Aug 20, 2026
Merged

fix(spp_drims): stop backorder dispatches bypassing the DRIMS request#390
emjay0921 merged 7 commits into
19.0from
fix/1087-drims-backorder-visibility

Conversation

@emjay0921

Copy link
Copy Markdown
Contributor

Why is this change needed?

Validating a DRIMS dispatch short of its demand and choosing Create Backorder processed the remainder entirely outside the request workflow. Reproduced on a dev instance before changing anything:

  • The request stayed at Dispatched while 10 of 100 units sat in a Ready backorder.
  • Nobody was told: 0 activities, 0 alerts, no chatter entry (message_post appeared nowhere in the module).
  • Odoo builds a backorder with picking.copy(), so every field left at the default copy=True was inherited. Three consequences, all measured:
    • Beneficiary double-count. beneficiary_count was inherited and hazard_incident.py:341 sums it over every done dispatch. Validating the 10-unit backorder moved drims_beneficiaries_served 1500 → 2000 — one 100-unit distribution to 500 people reporting 1000 served.
    • The beneficiary guard was silently bypassed. button_validate() returned True with no prompt, because the inherited value already satisfied it. Nobody ever confirmed where the remaining units went.
    • False departure record. The backorder carried the parent's date_departed and driver_name — a departure timestamp predating its own existence, on goods still in the warehouse.

In a humanitarian context these are false coverage figures and undocumented aid movements, which is what the ticket flags as the real risk.

Not broken, and verified rather than assumed: waybill_number is already copy=False and regenerates, and drims_request_id / drims_request_line_id already carry onto the backorder and its split move — so the request link and per-line attribution were correct. Both are now covered by a regression test instead of being "fixed".

How was the change implemented?

copy=False on the per-shipment facts (stock_picking.py) — beneficiary count, departure/arrival, the pod_* block, transport and driver details, discrepancy notes, drims_return_id. Declarative, so it fixes every copy path rather than just backorders, including the Duplicate action producing a dispatch that claims a delivery. It also makes the existing beneficiary guard fire on the backorder.

Coordinator notification (request.py) — _create_backorder() routes DRIMS dispatch backorders to their request, which posts an internal note and schedules a to-do activity. Coordinators are resolved by mirroring rule_request_coordinator_scope (security/rules.xml), matching users whose drims_area_ids cover the destination area or any ancestor, so the notification reaches exactly those permitted to see the request. Runs sudo() because a warehouse officer may sit outside the request's area scope and this is system bookkeeping, not a user edit.

Request state — reopens at allocated (Ready for Dispatch) while a backorder is pending, and re-advances to dispatched once nothing is outstanding.

Quantity reconciliation (request_line.py, stock_move.py) — action_create_dispatch counts a quantity as dispatched when it is committed to a picking rather than when it ships, so that running total is rebuilt from the moves that still stand on _action_done / _action_cancel. One reconciliation covers a cancelled backorder, a cancelled dispatch, and declining Create Backorder. That last path cancels no move at all — Odoo just drops the excess demand — so a cancellation hook alone would have missed it.

quantity_dispatched keeps its existing "committed to a picking" meaning, so OP#1033's partial-dispatch behaviour is untouched.

New unit tests

spp_drims/tests/test_dispatch_backorder.py — 12 tests:

  • Per-shipment facts not inherited: beneficiary count; departure, arrival, driver, POD.
  • The beneficiary guard fires on the backorder rather than being pre-satisfied.
  • Identity that should carry does: request link, DRIMS type, incident, own unique waybill, per-line attribution across the move split.
  • Request reopens from dispatched while the backorder is pending, and returns to dispatched once it ships.
  • Incident beneficiaries are not double-counted (500 + 40 entered = 540, not 1000).
  • Coordinator gets the note and the activity; no coordinator configured still logs and does not break validation.
  • Quantity released on a cancelled backorder, on declining Create Backorder, and on cancelling the whole dispatch.

Unit tests executed by the author

Full module suite, run after merging the current 19.0:

0 failed, 0 error(s) of 259 tests

All 12 new tests confirmed executing by name in the log rather than inferred from the total. OP#1033's existing partial-dispatch assertions still pass. ./spp lint (ruff + ruff-format) passes on all six files.

Also verified in the running app, not only in tests: the backorder shows beneficiary_count=0, no departure, no driver and its own waybill while keeping request/type/incident/area; the request reopens to allocated; the coordinator receives both the note and an assigned activity; the guard fires; the incident counts 540; and on the cancel path total_dispatched drops to 90 with Create Dispatch succeeding for the remaining 10.

How to test manually

Requires a DRIMS warehouse with stock, and a user in DRIMS District Coordinator whose DRIMS Areas include the request's destination area.

  1. Create a request for 100 units, submit, approve, allocate, then Create Dispatch.
  2. On the dispatch, fill Estimated Beneficiaries Reached (500) and Distribution Area, and click Confirm Departure.
  3. In the Operations tab set Quantity to 90 (demand 100) and click ValidateCreate Backorder.
  4. On the backorder, confirm the DRIMS tab shows no beneficiary count, no departure date and no driver, and that it has its own waybill number.
  5. On the request, confirm the state is back to Ready for Dispatch, the chatter names the backorder, and the coordinator has an activity "Release dispatch backorder …".
  6. Validate the backorder with no beneficiary count — it must refuse. Enter 40 and validate.
  7. Confirm the request returns to Dispatched and the incident dashboard's beneficiaries-served rose by 540 total, not 1000.
  8. Cancel path: repeat steps 1-3, then cancel the backorder. The request must stay actionable — Create Dispatch should offer the remaining 10 units rather than refusing with "Nothing left to dispatch".

Related links

  • OP#1087 — DRIMS: Backorder dispatch bypasses DRIMS request allocation and coordinator visibility

Notes for the reviewer

readme/HISTORY.md and the manifest version are deliberately untouched — per our convention those land on 19.0 after merge, to avoid conflicts between concurrent PRs against the same module. The entry for this change would be fix(spp_drims): stop backorder dispatches bypassing the DRIMS request at 19.0.2.0.1.

Two adjacent gaps found while investigating, both left alone as out of scope:

  1. quantity_delivered is never written by any production code in the module — only by tests. So total_delivered and fulfillment_pct are permanently 0, and alert.py:276-282's "quantity needed" always reports the full requested amount. That is the delivered side, which OP#1088's Confirm Delivery popup owns.
  2. OP#1057 locks the Operations tab against adding products but will not stop reducing a quantity, which is what triggers a backorder. Complementary to this fix, no overlap.

🤖 Generated with Claude Code

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
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Comment thread spp_drims/models/request.py Fixed
Comment thread spp_drims/models/request.py Fixed
Comment thread spp_drims/models/request.py Fixed
Comment thread spp_drims/models/request.py Fixed
Comment thread spp_drims/models/request.py Fixed
Comment thread spp_drims/models/request_line.py Fixed
Comment thread spp_drims/models/request_line.py Fixed
Comment thread spp_drims/models/request_line.py Fixed
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.38%. Comparing base (64e6b31) to head (a8f7d4e).
⚠️ Report is 1 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_drims/models/request.py 94.00% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #390      +/-   ##
==========================================
- Coverage   72.66%   72.38%   -0.28%     
==========================================
  Files         329     1010     +681     
  Lines       24298    60913   +36615     
==========================================
+ Hits        17655    44091   +26436     
- Misses       6643    16822   +10179     
Flag Coverage Δ
spp_base_common 91.07% <ø> (ø)
spp_drims 82.82% <96.77%> (+0.58%) ⬆️
spp_drims_sl_demo 69.01% <ø> (ø)
spp_programs 65.27% <ø> (ø)
spp_registry 87.22% <ø> (+0.07%) ⬆️
spp_security 69.56% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_drims/models/request_line.py 84.21% <100.00%> (+1.85%) ⬆️
spp_drims/models/stock_move.py 100.00% <100.00%> (ø)
spp_drims/models/stock_picking.py 81.45% <100.00%> (+4.66%) ⬆️
spp_drims/models/request.py 93.29% <94.00%> (+0.09%) ⬆️

... and 700 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
Cancelling or declining a backorder stopped releasing the quantity after
PR #396 merged. OP#1079 turned the request line's quantity_dispatched into
a stored compute over per-warehouse allocation rows, and
_reconcile_quantity_dispatched still assigned to the line. A second
dispatch then refused with "Nothing left to dispatch on this request".

Rebuild the allocation rows instead. Every dispatch move carries the
allocation it draws from - action_create_dispatch is the only thing that
creates them and always stamps drims_allocation_id, and a backorder copies
the link - so splitting the reconciled total per allocation is unambiguous.

Nothing was wrong when this ticket passed QA; the ground moved underneath it
after merge.

The tests were weaker than they looked. Writing a stored compute does
persist, until something retriggers it, so only one of the three release
tests failed - the other two asserted the line and passed on ordering luck
while the allocation underneath was left at the full dispatched quantity.
That is the figure _drims_available_quantity subtracts, so the released
stock was never actually freed for re-allocation. All three now assert the
allocation rows, and all three fail without the fix.

Also records why each sudo() in this ticket's code is deliberate. Those
findings are what has been failing pre-commit on PR #390 since it opened.
@emjay0921
emjay0921 marked this pull request as ready for review August 18, 2026 06:07

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the best-engineered PR of the current batch, and the PR body's rigor held up under verification — the measured reproductions, the "verified rather than assumed" items, and the OP#1079 subtlety are all real.

What I checked against the Odoo 19 source and this repo:

  • Both hook signatures match exactly: stock.move._action_done(cancel_backorder=False) and stock.picking._create_backorder(backorder_moves=None).
  • drims_allocation_id pre-exists on stock.move, is stamped by action_create_dispatch (request.py:843), and is kept distinct on move merges — so rebuilding the dispatched totals onto the allocation rows stands on solid ground. Catching that a write to the line's stored compute is silently discarded (and fixing it at the allocation level, with _assert_released pinning it in tests) is exactly right.
  • button_validate's result is True vs wizard-dict distinction is correct, and the pending-backorder check prevents state flip-flop during the short validation itself.
  • The reopen→re-dispatch loop closes: allocated is precisely what action_create_dispatch requires, and the cancel test proves the remaining balance re-dispatches.
  • The tests drive the real stock.backorder.confirmation wizard (process() / process_cancel_backorder()), including the decline path that no cancellation hook could see, and the no-coordinator path.

1. Blocker — version bump

spp_drims stays at 19.0.3.0.0. This is the convention ask, not a breakage one: the change is pure Python plus field-attribute changes (copy=False is registry-level), so nothing malfunctions without -u — but the module ships in 2026.08 and same-version changes give upgraders no signal (#441). With the bump added I'm happy to approve.

One practical wrinkle you already flagged on OP#1087: with #391/#392/#393/#433 (plus #416/#414) all touching spp_drims, the concrete version number is a merge-time decision — suggest each PR takes the next patch version and prepends its HISTORY entry when it rebases for merge, in whatever order Edwin sets for the queue.

2. Non-blocking suggestions

  • Re-advance hook placement: _sync_state_after_dispatch_done fires only from button_validate. A backorder validated through another path (API, barcode flow, a direct _action_done) reconciles quantities via the move hook but never re-advances the request — it stays allocated with everything shipped. Hooking picking _action_done instead of (or in addition to) button_validate closes that; your pending-backorder check already guards against premature advance.
  • Audit trail: the sudo'd message_post makes the note's author OdooBot, and neither the note nor the activity names who validated short. Worth including the acting user (captured before the sudo()) in the message body — in a humanitarian-accountability context, "who shipped short" is part of the record this PR otherwise builds so carefully.
  • Tiny: _get_state_by_code duplicates the search inside the existing _set_state_by_code — the setter could reuse the getter.

…ok, named audit trail

Version bump to 19.0.3.0.1 with its changelog entry. Nothing malfunctions
without an upgrade here — the change is Python plus registry-level field
attributes — but the module ships in a release, and a same-version change gives
upgraders no signal.

The re-advance moves from button_validate to picking._action_done. It used to
fire only for the web client's Validate button, so a backorder released through
the API, the barcode flow or a direct _action_done reconciled its quantities
through the move hook and then left the request sitting at "allocated" with
everything already shipped. _action_done is the point every path goes through,
and running after super() means any backorder has already been split off, which
is what the sync inspects before advancing.

The backorder note now names who validated short. It is posted through sudo, so
OdooBot authors it; in a humanitarian-accountability trail "who shipped short"
is part of the record.

_set_state_by_code reuses _get_state_by_code instead of repeating its search.

The Semgrep pragmas move onto the line each finding anchors to, matching the
form used elsewhere in the repo. The rationale comments above each sudo already
explained why it is needed; the placement is what the scanner reads.

Two tests added: a backorder validated outside the web client advances the
request, and the note names the acting user.
Moving the pragma onto the matched line dropped odoo-sudo-without-context,
which the local semgrep hook then flagged. Both rules match that expression,
so both ids belong on it.
@emjay0921

Copy link
Copy Markdown
Contributor Author

Thanks — the blocker and all three suggestions are done, plus the Semgrep comments. Pushed as a08c1890 and a8f7d4e6.

1. Version bump — done. spp_drims19.0.3.0.1 with its HISTORY line, taking the next patch as you suggest; the concrete number is a merge-time reconciliation across the queue.

2. Re-advance hook — moved to picking._action_done. You were right that this was a real hole rather than a stylistic point: the move hook reconciled quantities on every path, but the request only re-advanced for the web client's Validate button, so an API or barcode release left it at allocated with everything shipped. It now runs after super()._action_done(), which is also the point where Odoo has already split off the backorder — the thing _sync_state_after_dispatch_done inspects before advancing. button_validate keeps only the KPI cache invalidation, and the result is True dance is gone with it, since _action_done only runs when the transfer really is done.

There is a test for it that deliberately bypasses button_validate and calls _action_done directly, so the non-UI path is pinned rather than assumed.

3. Audit trail — the note now names the validating user. Captured from self.env.user before the sudo(), so the body reads "was validated short of its demand by X" even though OdooBot authors the note. Tested.

4. _set_state_by_code now reuses _get_state_by_code.

On the eight Semgrep comments: each of those sudo() calls already carried a rationale comment and a nosemgrep pragma, but the pragma sat on the line above a multi-line expression rather than on the line the finding anchors to. I moved them onto the matched line, matching the form used in spp_branding_kit/utils.py.

Worth recording how that went, because it is a small lesson in trusting the tool over the reasoning: doing so initially introduced a finding. The res.users lookup matches two rules, and in reformatting I kept only odoo-sudo-on-sensitive-models, so odoo-sudo-without-context started firing — the local semgrep pre-commit hook caught it, and a8f7d4e6 restores both ids. It also confirms the pragmas are read as written; the placement was the whole problem.

spp_drims suite: 277 tests, 0 failed (two new). pre-commit clean including the semgrep hook, README regenerated.

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — all four items addressed, and the hook relocation made the code simpler as well as more correct.

Verified on a08c1890 / a8f7d4e6:

  • Bump to 19.0.3.0.1 with its changelog entry, honestly framed as signal-only for this change — which matches the original assessment.
  • The re-advance now lives in _action_done, after super(), by which point any backorder has already been split off — which is exactly what _sync_state_after_dispatch_done inspects before advancing. Every path funnels through it (web client, API, barcode, direct call), and the fragile result is True wizard-dict distinction in button_validate is gone entirely. The new test validating a backorder outside the web client pins the path that used to strand requests at allocated.
  • The note names who shipped short, with a test asserting the acting user's display name in the body. Since sudo() hasn't switched env.user since Odoo 13, the name captured is genuinely the officer — and carrying it in the body keeps the record robust regardless of how the message author renders.
  • _set_state_by_code reuses _get_state_by_code, and the semgrep pragmas sit on their anchor lines with the rationale comments intact. CI fully green.

One merge-time line, same family as the rest of the queue: spp_drims now has four approved-or-approaching PRs carrying four different bump numbers (#414 → 4.0.0, #416 → 3.1.0, #433 → 3.0.4, this one → 3.0.1). All correct individually; each renumbers mechanically at its merge-time rebase in the order Edwin sets, and only the migration-carrying one (#416) has a directory to move with it.

Merge stays with Edwin per the usual flow.

@emjay0921
emjay0921 merged commit 2bf3f67 into 19.0 Aug 20, 2026
22 checks passed
@emjay0921
emjay0921 deleted the fix/1087-drims-backorder-visibility branch August 20, 2026 02:39
emjay0921 added a commit that referenced this pull request Aug 20, 2026
#390 landed spp_drims 19.0.3.0.1, so the manifest version, the top of the
changelog and tests/__init__ all conflicted. Kept this branch's 19.0.3.0.4 and
both changelog entries newest-first; tests/__init__ takes both imports, since
each branch registered its own test module. README.rst and the description page
are generated from readme/, so they were rebuilt rather than merged by hand.
gonzalesedwin1123 added a commit that referenced this pull request Aug 20, 2026
Drift back-merge per the staging plan (19.0 advanced 11 commits since the
batch-2 cut: #303, #322, #323, #390, #391, #393, #411, #412, #414, #416,
#433). Conflicts were confined to spp_farmer_registry_demo metadata:
batch 2's #336 demo fix claimed version 19.0.2.1.2, which 19.0 has since
consumed (#412=.2, #322=.3, #323=.4). Resolution re-bumps the batch-2
farmer-demo change to 19.0.2.1.5 with its HISTORY entry moved on top;
README.rst/index.html regenerated via the pinned oca-gen hook. The
change ships no migration directory, so the collision was metadata-only.
The generator code change itself auto-merged cleanly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants