Rework the acquisition order-line editing model (commitment vs. live target) + order claims/reminders
Pre-requisites:
- Confirm the workflow with the acquisition module users
- Develop in pair
Context
AcqOrderLine.extended_validation (and the DisallowedByOrderStatus permission) gate line editing on the aggregate order status. This has two weaknesses:
- It ignores the line's own received state. While the order is
partially_received, a line that is itself already fully received can still be edited. The guard only ever looks at the order status, never the line's received_quantity.
- There is no
quantity >= received_quantity invariant. Nothing prevents editing the ordered quantity below what has already been received. When that happens, unreceived_quantity goes negative and the line can never compute back to received — a stuck partially_received.
This surfaced while fixing a related bug (cancelling the last non-received line of a partially received order — see the accompanying fix(acquisition): allow cancelling the last non-received order line). That fix is a targeted patch; the underlying model needs a decision.
The modelling question
There's a legitimate need behind the looseness: e.g. "ordered 3 copies, received 1, the book is now unavailable — finalize the line with 1." Two coherent ways to support it, mutually exclusive:
Model A — order line is a live target, editable to match reality
Editing the ordered quantity down to what was received reconciles the line and lets the order close.
- Pros: lighter to build (mostly add the
quantity >= received_quantity invariant + gate editing on the line's own receipts instead of the order status).
- Cons: rewrites what was actually ordered (loses audit trail), and editing the amount after receipts churns the encumbrance/expenditure math.
Model B — order line is a commitment; receipts are the actuals; short lines are closed (recommended)
- Order line quantity = what was ordered, effectively frozen once the order is sent.
- Receipt lines = what actually came.
- A new terminal state
closed/complete, distinct from cancelled (nothing received / abandoned) and received (everything received), meaning "no more expected." The 3-ordered/1-received line becomes closed; the order can reach received; the trail keeps "ordered 3, received 1, closed the remaining 2".
- A librarian action "close line / close order" (a.k.a. "cancel remaining copies").
Recommendation: Model B. It preserves history, keeps the encumbrance math honest (no retro-editing of a committed amount), and matches how mature acquisition modules behave.
Proposed permission/validation redesign (Model B)
Gate on the two axes that actually matter, in one place, instead of the aggregate order status:
- Editing line fields (quantity/amount/document): allowed only while the order is
pending (not yet sent).
- Cancel / un-cancel / close: gated on the line's own
received_quantity, not the order status:
received_quantity == 0 → may cancel/un-cancel;
0 < received_quantity < quantity → may close (finalize the shortfall);
- fully received → terminal.
- A dedicated cancel/close endpoint or action rather than routing through the generic
update (avoids the double-commit/re-validate footgun that generated the recent bug series).
Dependency: order claims / reminders
A future order-reminder ("claim") feature — remind the vendor about unreceived items — depends on this model:
- A claim is the query "which lines are still outstanding and overdue": status
ordered/partially_received, not cancelled, not closed, with quantity − received_quantity > 0, and order_date older than a claim delay.
- Model B's
closed state is exactly what removes abandoned shortfalls from the claimable set, so reminders don't nag the vendor for items the library has given up on. With a frozen ordered quantity, "outstanding since date X" is a stable fact.
- Model A would need a
closed-like concept reintroduced just to make claims sane.
Additions the claim feature needs (orthogonal to A/B, easier on B):
- an expected/claim date on the order, or a per-vendor claim-delay config;
- a new
NotificationType (e.g. ACQUISITION_ORDER_CLAIM) dispatched like the existing send_order notification, listing outstanding lines;
- claim tracking (count + dates), mirroring the existing serial-issue
claim_notifications pattern at the order-line level.
Suggested sequencing
- Decide Model A vs. B (recommend B).
- Implement the editing/cancel/close permission+validation redesign + the
quantity >= received_quantity invariant.
- Build order claims/reminders on top, using
closed to scope the outstanding set.
Rework the acquisition order-line editing model (commitment vs. live target) + order claims/reminders
Pre-requisites:
Context
AcqOrderLine.extended_validation(and theDisallowedByOrderStatuspermission) gate line editing on the aggregate order status. This has two weaknesses:partially_received, a line that is itself already fully received can still be edited. The guard only ever looks at the order status, never the line'sreceived_quantity.quantity >= received_quantityinvariant. Nothing prevents editing the ordered quantity below what has already been received. When that happens,unreceived_quantitygoes negative and the line can never compute back toreceived— a stuckpartially_received.This surfaced while fixing a related bug (cancelling the last non-received line of a partially received order — see the accompanying
fix(acquisition): allow cancelling the last non-received order line). That fix is a targeted patch; the underlying model needs a decision.The modelling question
There's a legitimate need behind the looseness: e.g. "ordered 3 copies, received 1, the book is now unavailable — finalize the line with 1." Two coherent ways to support it, mutually exclusive:
Model A — order line is a live target, editable to match reality
Editing the ordered quantity down to what was received reconciles the line and lets the order close.
quantity >= received_quantityinvariant + gate editing on the line's own receipts instead of the order status).Model B — order line is a commitment; receipts are the actuals; short lines are closed (recommended)
closed/complete, distinct fromcancelled(nothing received / abandoned) andreceived(everything received), meaning "no more expected." The 3-ordered/1-received line becomesclosed; the order can reachreceived; the trail keeps "ordered 3, received 1, closed the remaining 2".Recommendation: Model B. It preserves history, keeps the encumbrance math honest (no retro-editing of a committed amount), and matches how mature acquisition modules behave.
Proposed permission/validation redesign (Model B)
Gate on the two axes that actually matter, in one place, instead of the aggregate order status:
pending(not yet sent).received_quantity, not the order status:received_quantity == 0→ may cancel/un-cancel;0 < received_quantity < quantity→ may close (finalize the shortfall);update(avoids the double-commit/re-validate footgun that generated the recent bug series).Dependency: order claims / reminders
A future order-reminder ("claim") feature — remind the vendor about unreceived items — depends on this model:
ordered/partially_received, not cancelled, not closed, withquantity − received_quantity > 0, andorder_dateolder than a claim delay.closedstate is exactly what removes abandoned shortfalls from the claimable set, so reminders don't nag the vendor for items the library has given up on. With a frozen ordered quantity, "outstanding since date X" is a stable fact.closed-like concept reintroduced just to make claims sane.Additions the claim feature needs (orthogonal to A/B, easier on B):
NotificationType(e.g.ACQUISITION_ORDER_CLAIM) dispatched like the existingsend_ordernotification, listing outstanding lines;claim_notificationspattern at the order-line level.Suggested sequencing
quantity >= received_quantityinvariant.closedto scope the outstanding set.