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.
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.
| Route | Result |
|---|---|
| Add a Product | 50 units of a never-requested item shipped done, with drims_request_line_id empty |
| Unlock, then raise Demand | Demand 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.
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:
-
Beneficiaries were double counted.
beneficiary_countwas inherited, and the incident sums it across every completed dispatch. Validating a 10-unit backorder moved the incident's beneficiaries served from 1500 to 2000. One 100-unit distribution to 500 people was reporting 1000. -
The beneficiary guard was silently bypassed. There is a check that asks
who received the goods. It returned
Truewithout prompting, because the inherited value already satisfied it. Nobody ever confirmed where the remaining units went. - A departure record that predated its own record. The backorder carried the parent's departure timestamp and driver name, describing a journey for goods still sitting in the warehouse.
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.
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.
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.