← Michael John Gonzales

Three ways to ship what nobody approved

A disaster relief dispatch is created from an approved request. Three separate routes let it ship more than the approval allowed, and the third was hiding behind a premise that turned out to be false.

OpenSPP · spp_drims · August 2026

DRIMS is the disaster relief part of OpenSPP. When a district needs relief goods, someone raises a request, somebody with authority approves it, stock is allocated against it, and a dispatch goes out from a warehouse. The approval step is the point of the whole thing. It is the record that says a named person agreed to send this much of this item to this place.

A dispatch in Odoo is a stock.picking. That is the right call, because you get reservations, moves, backorders and the rest of the warehouse machinery for free. It also means a dispatch arrives carrying every behaviour Odoo gives a delivery order, including some that quietly make no sense once the delivery is supposed to match an approval.

The approval was real. The enforcement was not. I found three different doors past it, and only the first was in the ticket.

Door one: the form stayed editable

A dispatch is generated from the approved request, but its Operations tab stayed editable once the dispatch was Ready. I reproduced it on a dev instance before touching anything, using a request for 100 units of a single approved item.

RouteResult
Add a Product50 units of a never-requested item shipped done, with drims_request_line_id empty
Unlock, then raise DemandDemand 100 → 150. 150 units shipped against a request for 100

Both validated with no warning. The request line was left reading dispatched=150 against requested=100, and the unapproved item's unit cost was booked into the incident's drims_distributed_value as distributed relief value. In a humanitarian context that is unauthorised aid movement plus a corrupted distribution figure, which is worse, because the figure is what somebody reports upward.

The guard checks two things at the top of button_validate: every live move must trace back to a line of this request, and nothing may ship beyond that line's allocation, counting what earlier dispatches already sent.

The interesting decision was what to key it on. Odoo sets an additional flag on moves added to a picking after the fact, and that looks exactly like the discriminator you want. It is not. additional is only set when a line is added through the form. A move created over RPC or by an import leaves it False and walks straight past a check built on it. So the guard keys on drims_request_line_id instead, and there is a test asserting that precondition so the reasoning survives the next person to read it.

Capping at the allocated quantity rather than the requested one was deliberate too. Allocation never exceeds the request, so capping at the allocation enforces the approval transitively, and avoids introducing a second notion of "the approved quantity" that could drift away from the first.

What I did not do was make the quantity readonly. Entering less than Demand is how a partial dispatch and its backorder are produced, and that is a real workflow.

OpenSPP2 #391 · 8 files, +457/-14 · 9 new tests

Door two: the backorder inherited a delivery that never happened

Closing door one opened a better question. Partial dispatch produces a backorder, and Odoo builds a backorder with picking.copy(). Every field left at the default copy=True comes along with it.

That gave the backorder a set of facts about a shipment it had no part in:

The fix is copy=False on the per-shipment facts. It is declarative, so it covers every copy path rather than the backorder specifically, including the Duplicate action that would otherwise produce a fresh dispatch claiming a completed delivery. It also makes the existing beneficiary guard start firing again, which is the part I liked: the check was already correct, it was just being handed a pre-filled answer.

Two things turned out not to be broken, and I want to be precise about those because the temptation is to "fix" them anyway. The waybill number was already copy=False and regenerated correctly, and the request link carried onto both the backorder and the split move, so per-line attribution was intact. Both got regression tests instead of changes.

OpenSPP2 #390 · 10 files, +690/-22 · 12 new tests

Door three: the one where the ticket was wrong

The third ticket was cosmetic on its face. A dispatch is confirmed the moment it is created, so it never sits in Draft, yet Draft and Waiting were both drawn as greyed-out future steps on the status bar. Hide two unreachable states.

The ticket explained why Waiting was unreachable: stock is already reserved at the allocation step. That is the kind of sentence it is easy to implement against without checking. I checked, because the whole change rests on it.

It is not true. Allocation only writes a quantity onto the request line. It creates no Odoo reservation at all. So two requests can allocate the same units, and whichever dispatches second finds nothing to reserve:

request A: quantity_allocated=100     request B: quantity_allocated=100
dispatch A: OP86/OUT/00001  assigned   (Ready)    reserved=100.0
dispatch B: OP86/OUT/00002  confirmed  (Waiting)  reserved=0.0

Waiting is reachable. A warehouse running short will land there.

Hiding it is still the right change, but for a different reason than the ticket gave. The status bar widget filters its states with value === currentValue || visibleSelection.includes(value), which means an excluded state still renders when it is the current one. So Waiting disappears only as a greyed-out future step. A dispatch genuinely short of stock still shows Waiting, and still shows it to the warehouse staff who need to see it.

The other half was not touching what I did not need to. Odoo renders two status bars for pickings, split by type, and editing the shared one would have dropped Draft from every non-incoming transfer in the database. A third, dispatch-only bar was narrower and left the other two alone.

Both facts are pinned by tests named after the premises they protect, so neither gets simplified later by someone who assumes what the ticket assumed.

OpenSPP2 #392 · 7 files, +269/-8 · 5 new tests

What I take from it

All three doors are the same shape. A platform gives you generic behaviour, you build a domain rule on top, and the generic behaviour keeps running underneath where the rule cannot see it. The editable Operations tab, the copy=True defaults and the shared status bar were all Odoo being perfectly reasonable about a delivery order. They only became bugs once a delivery was supposed to match an approval.

The habit that found them was reproducing each one on a dev instance before writing any code. That is what turned "the Operations tab is editable" into two measured vectors with numbers attached, and it is the only reason I noticed the ticket's premise was wrong on the third. A fix built on a false premise still passes review. It just fails later, quietly, when somebody relies on the reason rather than the change.

These are public pull requests on OpenSPP/OpenSPP2, so the code, the tests and the review discussion are all readable. OpenSPP is open source software that governments and NGOs use to run cash transfers and aid programmes.