diff --git a/spp_base_common/__manifest__.py b/spp_base_common/__manifest__.py
index b9ff39a3a..a3dba3e53 100644
--- a/spp_base_common/__manifest__.py
+++ b/spp_base_common/__manifest__.py
@@ -38,6 +38,7 @@
"spp_base_common/static/src/xml/custom_list_create_template.xml",
"spp_base_common/static/src/js/filterable_radio_field.js",
"spp_base_common/static/src/xml/filterable_radio_field.xml",
+ "spp_base_common/static/src/js/integer_positive_required.js",
"spp_base_common/static/src/xml/pager_hide_single.xml",
"spp_base_common/static/src/scss/pager_hide_single.scss",
],
diff --git a/spp_base_common/static/src/js/integer_positive_required.js b/spp_base_common/static/src/js/integer_positive_required.js
new file mode 100644
index 000000000..a5e501ea9
--- /dev/null
+++ b/spp_base_common/static/src/js/integer_positive_required.js
@@ -0,0 +1,59 @@
+/** @odoo-module **/
+/*
+ * `integer_positive_required` field widget — a reusable Integer field that
+ * treats 0 as invalid when the field is required.
+ *
+ * Why: Odoo 19's `_checkValidity` (web/static/src/model/relational_model/
+ * record.js) explicitly skips numeric field types (`boolean`, `float`,
+ * `integer`, `monetary`) from the required-empty validation. That means
+ * a required Integer with value 0 is NEVER added to `_invalidFields`,
+ * the `o_field_invalid` class is NEVER applied, and the user gets no
+ * visual cue (asterisk on label / pink highlight on input) that the
+ * field is unfilled. The model-side validation still rejects 0 on save,
+ * but the form is silent until then.
+ *
+ * `fieldVisualFeedback` calls `field.isValid(record, fieldName, fieldInfo)`
+ * BEFORE falling back to `record.isFieldInvalid(...)`. So a widget can
+ * override `isValid` to plug its own check into the visual-feedback
+ * pipeline. We use that hook to mark the field invalid when value <= 0
+ * AND `required` evaluates true.
+ *
+ * Usage:
+ *
+ */
+
+import {evaluateBooleanExpr} from "@web/core/py_js/py";
+import {registry} from "@web/core/registry";
+import {integerField} from "@web/views/fields/integer/integer_field";
+
+export const integerPositiveRequiredField = {
+ ...integerField,
+ isEmpty: (record, fieldName) => {
+ const value = record.data[fieldName];
+ return value === false || value === 0;
+ },
+ isValid: (record, fieldName, fieldInfo) => {
+ const value = record.data[fieldName];
+ // Field is valid if value is a positive integer.
+ if (typeof value === "number" && value > 0) {
+ return true;
+ }
+ // 0, false (unset), or non-numeric — only valid when field is not
+ // required. Evaluate the required modifier against the record's
+ // eval context (it may be a dynamic expression like
+ // `drims_type == 'request_dispatch'`). `fieldInfo.required` is
+ // undefined for fields with no required modifier; guard against it
+ // so `evaluateBooleanExpr` is never handed `undefined` (which throws
+ // and crashes the form view).
+ const required = fieldInfo.required
+ ? evaluateBooleanExpr(fieldInfo.required, record.evalContextWithVirtualIds)
+ : false;
+ return !required;
+ },
+};
+
+registry
+ .category("fields")
+ .add("integer_positive_required", integerPositiveRequiredField);
diff --git a/spp_drims/__manifest__.py b/spp_drims/__manifest__.py
index 403cad6c6..5cd7472c7 100644
--- a/spp_drims/__manifest__.py
+++ b/spp_drims/__manifest__.py
@@ -17,6 +17,7 @@
"mail",
"stock",
"spp_alerts",
+ "spp_base_common",
"spp_security",
"spp_vocabulary",
"spp_area",
@@ -51,6 +52,7 @@
"views/menu_structure.xml",
# Wizards (before views, as views may reference wizard actions)
"wizard/bulk_approve_wizard.xml",
+ "wizard/request_reject_wizard_views.xml",
"wizard/report_4w_wizard_views.xml",
"wizard/stock_adjustment_wizard_views.xml",
"wizard/inter_warehouse_transfer_wizard_views.xml",
@@ -79,7 +81,15 @@
# Menus with actions (loaded last, after all views define their actions)
"views/menus.xml",
],
- "assets": {},
+ "assets": {
+ "web.assets_backend": [
+ "spp_drims/static/src/js/hide_dispatch_form_create.js",
+ "spp_drims/static/src/js/inspection_list_renderer.js",
+ "spp_drims/static/src/js/qty_split_progress_field.js",
+ "spp_drims/static/src/xml/qty_split_progress_field.xml",
+ "spp_drims/static/src/css/inspection_wizard.css",
+ ],
+ },
"application": True,
"installable": True,
"auto_install": False,
diff --git a/spp_drims/data/vocabulary_codes.xml b/spp_drims/data/vocabulary_codes.xml
index 5358880e4..c8ea0a22e 100644
--- a/spp_drims/data/vocabulary_codes.xml
+++ b/spp_drims/data/vocabulary_codes.xml
@@ -155,12 +155,20 @@
Submitted20
-
-
- approved
- Approved
- 30
-
+
+
+
+
+ approved
+ Ready for Allocation
+ 30
+
+
rejected
@@ -173,12 +181,15 @@
Fulfilled50
-
-
- allocated
- Allocated
- 35
-
+
+
+
+
+ allocated
+ Ready for Dispatch
+ 35
+
+
dispatched
diff --git a/spp_drims/models/donation.py b/spp_drims/models/donation.py
index 518ba8fa7..5938145f3 100644
--- a/spp_drims/models/donation.py
+++ b/spp_drims/models/donation.py
@@ -15,13 +15,17 @@
VOCAB_DONATION_STATES,
VOCAB_DONOR_TYPES,
VOCAB_DRIMS_TYPES,
- VOCAB_ITEM_CONDITIONS,
- VOCAB_ITEM_DISPOSITIONS,
VOCAB_RESTRICTIONS,
)
_logger = logging.getLogger(__name__)
+# Donation-line disposition codes that should NOT be stocked. Moves for these
+# get cancelled by `_exclude_non_accept_moves`, and if every line lands in
+# this set the donation has nothing left to stock — only Reject makes sense.
+NON_ACCEPT_DISPOSITIONS = ("return", "dispose", "quarantine")
+
+
# Valid state transitions: {from_state: [allowed_to_states]}
DONATION_STATE_TRANSITIONS = {
DONATION_STATE_ANNOUNCED: [DONATION_STATE_RECEIVED, DONATION_STATE_CANCELLED],
@@ -138,6 +142,9 @@ class DrimsDonation(models.Model):
compute="_compute_totals",
store=True,
)
+ has_acceptable_items = fields.Boolean(
+ compute="_compute_has_acceptable_items",
+ )
# Stock
picking_ids = fields.One2many(
@@ -225,6 +232,19 @@ def _compute_totals(self):
rec.total_value = sum(rec.line_ids.mapped("value"))
rec.line_count = len(rec.line_ids)
+ @api.depends("line_ids.disposition_id", "line_ids.quantity_received")
+ def _compute_has_acceptable_items(self):
+ # A line counts as "acceptable" (i.e. something the warehouse would
+ # stock) when it has a received qty > 0 and its disposition isn't one
+ # of the non-accept dispositions cancelled by `_exclude_non_accept_moves`.
+ # Lines with no disposition yet are treated as acceptable so the
+ # Stock button stays available while inspection is still in progress.
+ for rec in self:
+ rec.has_acceptable_items = any(
+ line.quantity_received > 0 and (line.disposition_id.code or "") not in NON_ACCEPT_DISPOSITIONS
+ for line in rec.line_ids
+ )
+
@api.depends("picking_ids")
def _compute_picking_count(self):
for rec in self:
@@ -362,13 +382,10 @@ def action_inspect(self):
def action_open_inspection_wizard(self):
"""Open the inspection wizard with pre-created records.
- This method creates the wizard and all line records BEFORE opening
- the wizard form. This is the standard Odoo pattern for wizards with
- interactive One2many fields - it ensures all records have database IDs
- so that buttons on lines work properly.
-
- All items default to 'New/Accept' status. Users can click 'Edit' on
- any row to modify condition/disposition for exceptions.
+ Wizard and line records are created before the form opens so each row
+ has a real database id (needed for inline buttons like "+ Add split").
+ Lines are created with no condition / no action — the operator must
+ explicitly set both per row (OP#963).
Returns:
dict: Action to open the wizard form.
@@ -381,43 +398,29 @@ def action_open_inspection_wizard(self):
if self.state != DONATION_STATE_RECEIVED:
raise UserError(_("Only received donations can be inspected."))
- # Get default condition (new) and disposition (accept)
- condition_new = self.env["spp.vocabulary.code"].search(
- [
- ("vocabulary_id.namespace_uri", "=", VOCAB_ITEM_CONDITIONS),
- ("code", "=", "new"),
- ],
- limit=1,
- )
- disposition_accept = self.env["spp.vocabulary.code"].search(
- [
- ("vocabulary_id.namespace_uri", "=", VOCAB_ITEM_DISPOSITIONS),
- ("code", "=", "accept"),
- ],
- limit=1,
- )
-
- # Create the wizard record first
wizard = self.env["spp.drims.inspection.wizard"].create(
{
"donation_id": self.id,
}
)
- # Create all line records with default values (New/Accept)
+ # OP#964: fall back to quantity_pledged when quantity_received is 0
+ # so wizard lines never open with an expected of 0 — that happens
+ # when a donation line is added after the donation was marked
+ # received (so action_mark_received didn't copy pledged → received
+ # for it), and would otherwise force the user into a quantity
+ # mismatch they cannot resolve.
line_vals = []
for donation_line in self.line_ids:
+ expected_qty = donation_line.quantity_received or donation_line.quantity_pledged
line_vals.append(
{
"wizard_id": wizard.id,
"donation_line_id": donation_line.id,
"product_id": donation_line.product_id.id,
"uom_id": donation_line.uom_id.id,
- "quantity_expected": donation_line.quantity_received,
- "quantity": donation_line.quantity_received,
- "condition_id": condition_new.id if condition_new else False,
- "disposition_id": disposition_accept.id if disposition_accept else False,
- "is_inspected": True, # Default to inspected (New/Accept)
+ "quantity_expected": expected_qty,
+ "quantity": expected_qty,
}
)
@@ -439,11 +442,27 @@ def action_stock(self):
This action:
1. Transitions the donation from 'inspected' to 'stocked' state
- 2. Validates all pending stock pickings (assigns and confirms moves)
- 3. Items become available in warehouse inventory
+ 2. **Cancels moves for non-accept dispositions** (OP#1030) so units
+ marked ``return``, ``dispose``, or ``quarantine`` during inspection
+ never enter usable inventory. Those donation lines must be handled
+ through a separate return / disposal flow.
+ 3. Validates the remaining moves on the pending pickings
+ 4. For lot/serial-tracked products, creates stock.lot records from
+ the donation line's ``lot_number`` (+ ``expiry_date`` if the
+ ``product_expiry`` module is installed) and attaches them to
+ the picking's move lines so ``button_validate()`` succeeds
+ 5. Items become available in warehouse inventory
+
+ Returns:
+ dict | None: a display_notification action summarising excluded
+ non-accept units, or ``None`` when everything was accepted.
Raises:
UserError: If donation is not in 'inspected' state.
+ UserError: If a tracked product line has no ``lot_number`` set.
+ UserError: If a serial-tracked product line has quantity > 1
+ (each serial must be unique; the donation line must be
+ split so quantity == 1).
"""
stocked_state = self.env["spp.vocabulary.code"].search(
[
@@ -452,17 +471,181 @@ def action_stock(self):
],
limit=1,
)
+ Lot = self.env["stock.lot"]
+ has_expiration = "expiration_date" in Lot._fields
+ excluded_summary = []
for rec in self:
if rec.state != DONATION_STATE_INSPECTED:
raise UserError(_("Only inspected donations can be marked as stocked."))
rec.state_id = stocked_state
# Validate the picking to complete the receipt
for picking in rec.picking_ids.filtered(lambda p: p.state not in ("done", "cancel")):
+ excluded_summary.extend(rec._exclude_non_accept_moves(picking))
+ # If every move was excluded, just cancel the picking — there
+ # is nothing left to validate.
+ remaining = picking.move_ids.filtered(lambda m: m.state != "cancel")
+ if not remaining:
+ picking.action_cancel()
+ continue
picking.action_assign()
- for move in picking.move_ids:
+ rec._assign_lots_to_picking(picking, Lot, has_expiration)
+ for move in remaining:
move.quantity = move.product_uom_qty
picking.button_validate()
+ if excluded_summary:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": _("Damaged / non-accept units excluded"),
+ "message": "\n".join(excluded_summary),
+ "type": "warning",
+ "sticky": True,
+ # Close the action chain so the underlying donation form
+ # re-reads (state badge, picking smart button, etc.).
+ # Without ``next``, display_notification returns without
+ # refreshing the record.
+ "next": {"type": "ir.actions.act_window_close"},
+ },
+ }
+ return None
+
+ def _exclude_non_accept_moves(self, picking):
+ """Reduce moves to only the accept portion of each product (OP#1030).
+
+ Per-move ``drims_donation_line_id`` is unreliable: when a donation
+ has multiple lines for the same product, Odoo's standard move-merge
+ logic can collapse them into a single move with one line reference,
+ losing the per-line disposition link. Instead, we sum
+ ``quantity_received`` of accept vs non-accept donation lines per
+ product, then walk the picking's moves for that product, keeping
+ them up to the per-product accept total and cancelling/reducing
+ the excess.
+
+ Works whether or not Odoo merged moves: with merge, one move per
+ product gets reduced; without merge, accept moves stay intact and
+ non-accept moves get cancelled.
+
+ Returns a list of human-readable summary lines for each excluded
+ donation line so the caller can roll them into a single
+ notification.
+ """
+ self.ensure_one()
+
+ accept_qty_by_product = {}
+ non_accept_by_product = {}
+ for line in self.line_ids:
+ if line.quantity_received <= 0:
+ continue
+ disposition_code = line.disposition_id.code or ""
+ if disposition_code in NON_ACCEPT_DISPOSITIONS:
+ non_accept_by_product.setdefault(line.product_id.id, []).append(line)
+ else:
+ accept_qty_by_product[line.product_id.id] = (
+ accept_qty_by_product.get(line.product_id.id, 0.0) + line.quantity_received
+ )
+
+ if not non_accept_by_product:
+ return []
+
+ moves_by_product = {}
+ for move in picking.move_ids:
+ if move.state in ("done", "cancel"):
+ continue
+ moves_by_product.setdefault(move.product_id.id, []).append(move)
+
+ excluded = []
+ for product_id, non_accept_lines in non_accept_by_product.items():
+ for line in non_accept_lines:
+ excluded.append(self._format_excluded_line(line))
+
+ accept_qty = accept_qty_by_product.get(product_id, 0.0)
+ remaining_to_keep = accept_qty
+ for move in moves_by_product.get(product_id, []):
+ if remaining_to_keep <= 0:
+ move._action_cancel()
+ elif move.product_uom_qty <= remaining_to_keep + 0.001:
+ remaining_to_keep -= move.product_uom_qty
+ else:
+ move.product_uom_qty = remaining_to_keep
+ remaining_to_keep = 0.0
+ return excluded
+
+ def _format_excluded_line(self, line):
+ return _(
+ "%(qty)s %(uom)s of %(product)s — disposition %(disposition)s "
+ "(excluded from usable stock; handle via the appropriate "
+ "return / disposal flow)."
+ ) % {
+ "qty": line.quantity_received,
+ "uom": line.uom_id.name,
+ "product": line.product_id.display_name,
+ "disposition": line.disposition_id.display,
+ }
+
+ def _assign_lots_to_picking(self, picking, Lot, has_expiration):
+ """Create + attach stock.lot for tracked-product moves on the picking.
+
+ For each move whose product is tracked by lot or serial, the
+ corresponding donation line (via ``drims_donation_line_id``)
+ carries the lot number and (optionally) the expiry date. This
+ method finds or creates the ``stock.lot`` and attaches it to the
+ move's move lines so the picking validates without Odoo's
+ "lot/serial required" UserError.
+ """
+ self.ensure_one()
+ for move in picking.move_ids:
+ tracking = move.product_id.tracking
+ if tracking == "none":
+ continue
+ line = move.drims_donation_line_id
+ if not line or not line.lot_number:
+ raise UserError(
+ _(
+ "Product %(product)s on donation %(donation)s requires a "
+ "lot/serial number. Please fill the Lot/Batch field on "
+ "the donation line before stocking."
+ )
+ % {
+ "product": move.product_id.display_name,
+ "donation": self.reference,
+ }
+ )
+ if tracking == "serial" and move.product_uom_qty > 1:
+ raise UserError(
+ _(
+ "Product %(product)s is serial-tracked but the donation "
+ "line provides one serial number for %(qty)s units. Each "
+ "serial must be unique — split the donation line into "
+ "%(qty)s separate lines (quantity 1 each)."
+ )
+ % {
+ "product": move.product_id.display_name,
+ "qty": int(move.product_uom_qty),
+ }
+ )
+ lot = Lot.search(
+ [
+ ("name", "=", line.lot_number),
+ ("product_id", "=", move.product_id.id),
+ ("company_id", "=", picking.company_id.id),
+ ],
+ limit=1,
+ )
+ if not lot:
+ lot_vals = {
+ "name": line.lot_number,
+ "product_id": move.product_id.id,
+ "company_id": picking.company_id.id,
+ }
+ if has_expiration and line.expiry_date:
+ lot_vals["expiration_date"] = line.expiry_date
+ lot = Lot.create(lot_vals)
+ for ml in move.move_line_ids:
+ if not ml.lot_id:
+ ml.lot_id = lot.id
+
def _change_state_and_cancel_pickings(self, new_state_code):
"""Helper to transition state and cancel pending pickings.
diff --git a/spp_drims/models/request.py b/spp_drims/models/request.py
index 1ed549bca..05725f586 100644
--- a/spp_drims/models/request.py
+++ b/spp_drims/models/request.py
@@ -443,6 +443,27 @@ def action_reject(self, reason=None):
rec._on_reject()
return True
+ def action_open_reject_wizard(self):
+ """Open the reject wizard to collect a required rejection reason (OP#966).
+
+ The Reject button on the form previously called ``action_reject``
+ directly and the user had no way to enter a reason, so the audit
+ trail lost the rationale. This action opens a small wizard that
+ collects a required reason text and then invokes ``action_reject``
+ with it.
+ """
+ self.ensure_one()
+ if self.approval_state not in ("pending", "submitted"):
+ raise UserError(_("Only pending requests can be rejected."))
+ return {
+ "type": "ir.actions.act_window",
+ "name": _("Reject Request"),
+ "res_model": "spp.drims.request.reject.wizard",
+ "view_mode": "form",
+ "target": "new",
+ "context": {"default_request_id": self.id},
+ }
+
def action_request_revision(self, notes=None):
"""Request changes from the requester."""
revision_state = self.env["spp.vocabulary.code"].search(
@@ -490,6 +511,13 @@ def action_allocate(self):
"""Allocate stock to fulfill this request using FEFO.
For UI, use action_open_allocation_wizard() to preview before allocating.
+
+ OP#1032: if the source warehouse has zero available stock for every
+ requested item, the FIFO loop is a no-op and the request would
+ silently advance to Ready for Dispatch with 0 allocated. Instead,
+ check the total allocated quantity after the run; if it's still 0
+ across all lines, raise so the state stays at Ready for Allocation
+ and the user is forced to pick a warehouse that actually has stock.
"""
for rec in self:
if rec.approval_state != "approved":
@@ -497,6 +525,15 @@ def action_allocate(self):
if not rec.source_warehouse_id:
raise UserError(_("Please select a source warehouse before allocation."))
rec._allocate_stock_fifo()
+ total_allocated = sum(rec.line_ids.mapped("quantity_allocated"))
+ if total_allocated <= 0:
+ raise UserError(
+ _(
+ "No stock available in the selected warehouse. "
+ "Please ensure the source warehouse has sufficient items "
+ "before allocating."
+ )
+ )
# Update state to allocated
allocated_state = self.env["spp.vocabulary.code"].search(
[
@@ -579,22 +616,23 @@ def _allocate_stock_fifo(self):
return True
def action_create_dispatch(self):
- """Create a dispatch picking for this allocated request (GAP-REQ-003).
+ """Create a dispatch picking for the not-yet-dispatched balance of this
+ request (GAP-REQ-003, OP#1033 — partial dispatches).
- This method:
- 1. Validates the request is allocated and has a source warehouse
- 2. Creates a stock.picking for outgoing delivery
- 3. Creates stock.move for each allocated line item
- 4. Confirms the picking
- 5. Updates request state to 'dispatched'
- 6. Opens the picking form
+ Each call creates a picking covering only the **remaining** allocated
+ quantity per line (``quantity_allocated - quantity_dispatched``). The
+ request state only advances to ``dispatched`` once every line has
+ ``quantity_dispatched >= quantity_requested`` — until then it stays at
+ ``allocated`` (Ready for Dispatch) so the button can be clicked again
+ when more stock is allocated.
Returns:
dict: Action to view the created picking.
Raises:
- UserError: If request is not allocated, no source warehouse, or
- no outgoing picking type found.
+ UserError: If request is not allocated, source warehouse missing,
+ outgoing picking type missing, or nothing remains to
+ dispatch on the current allocation.
"""
self.ensure_one()
if self.state != "allocated":
@@ -602,10 +640,15 @@ def action_create_dispatch(self):
if not self.source_warehouse_id:
raise UserError(_("Please select a source warehouse."))
- # Check for allocated lines
- allocated_lines = self.line_ids.filtered(lambda line: line.quantity_allocated > 0)
- if not allocated_lines:
- raise UserError(_("No items have been allocated for dispatch."))
+ # Lines that still have allocated stock not yet committed to a picking.
+ lines_to_dispatch = self.line_ids.filtered(lambda line: line.quantity_allocated - line.quantity_dispatched > 0)
+ if not lines_to_dispatch:
+ raise UserError(
+ _(
+ "Nothing left to dispatch on this request. Allocate additional "
+ "stock first before creating another dispatch."
+ )
+ )
# Get picking type for outgoing deliveries
picking_type = self.env["stock.picking.type"].search(
@@ -652,13 +695,15 @@ def action_create_dispatch(self):
}
picking = self.env["stock.picking"].create(picking_vals)
- # Create moves for each allocated line
+ # Create moves for each line's not-yet-dispatched balance, and track
+ # the running total on the request line.
Move = self.env["stock.move"]
- for line in allocated_lines:
+ for line in lines_to_dispatch:
+ qty_remaining = line.quantity_allocated - line.quantity_dispatched
Move.create(
{
"product_id": line.product_id.id,
- "product_uom_qty": line.quantity_allocated,
+ "product_uom_qty": qty_remaining,
"product_uom": line.uom_id.id,
"picking_id": picking.id,
"location_id": picking.location_id.id,
@@ -666,24 +711,28 @@ def action_create_dispatch(self):
"drims_request_line_id": line.id,
}
)
+ line.quantity_dispatched = line.quantity_dispatched + qty_remaining
# Confirm the picking
picking.action_confirm()
- # Update request state to dispatched
- dispatched_state = self.env["spp.vocabulary.code"].search(
- [
- (
- "vocabulary_id.namespace_uri",
- "=",
- "urn:openspp:vocab:drims:request-states",
- ),
- ("code", "=", "dispatched"),
- ],
- limit=1,
- )
- if dispatched_state:
- self.state_id = dispatched_state
+ # Only advance to ``dispatched`` once every line is fully dispatched
+ # against its requested quantity. Otherwise the request stays at
+ # ``allocated`` so the user can allocate more and dispatch again.
+ if all(line.quantity_dispatched >= line.quantity_requested for line in self.line_ids):
+ dispatched_state = self.env["spp.vocabulary.code"].search(
+ [
+ (
+ "vocabulary_id.namespace_uri",
+ "=",
+ "urn:openspp:vocab:drims:request-states",
+ ),
+ ("code", "=", "dispatched"),
+ ],
+ limit=1,
+ )
+ if dispatched_state:
+ self.state_id = dispatched_state
# Open the picking form
return {
diff --git a/spp_drims/models/stock_move.py b/spp_drims/models/stock_move.py
index 61d367cc4..e5dffbaf6 100644
--- a/spp_drims/models/stock_move.py
+++ b/spp_drims/models/stock_move.py
@@ -1,5 +1,5 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-from odoo import fields, models
+from odoo import api, fields, models
class StockMove(models.Model):
@@ -16,3 +16,16 @@ class StockMove(models.Model):
string="Donation Line",
help="Link to the donation line this move receives",
)
+
+ @api.model
+ def _prepare_merge_moves_distinct_fields(self):
+ """Keep DRIMS donation/request lines distinct when Odoo merges moves.
+
+ Without this, a multi-line donation for the same product collapses
+ into a single move on the receipt picking and the per-line
+ ``drims_donation_line_id`` link is lost — which OP#1030's stocking
+ logic relies on to exclude non-accept dispositions.
+ """
+ fields_list = super()._prepare_merge_moves_distinct_fields()
+ fields_list += ["drims_donation_line_id", "drims_request_line_id"]
+ return fields_list
diff --git a/spp_drims/models/stock_picking.py b/spp_drims/models/stock_picking.py
index 0f68666bf..703c4403f 100644
--- a/spp_drims/models/stock_picking.py
+++ b/spp_drims/models/stock_picking.py
@@ -279,16 +279,18 @@ def button_validate(self):
if not picking.beneficiary_count or picking.beneficiary_count <= 0:
raise UserError(
_(
- "Please enter the number of beneficiaries served for dispatch %s. "
- "This is required for DRIMS distribution tracking."
+ "Please enter the number of beneficiaries served for "
+ "dispatch %s under the DRIMS tab. This is required for "
+ "DRIMS distribution tracking."
)
% picking.name
)
if not picking.beneficiary_area_id:
raise UserError(
_(
- "Please select the distribution area for dispatch %s. "
- "This is required for DRIMS geographic reporting."
+ "Please select the distribution area for dispatch %s "
+ "under the DRIMS tab. This is required for DRIMS "
+ "geographic reporting."
)
% picking.name
)
diff --git a/spp_drims/models/stock_warehouse.py b/spp_drims/models/stock_warehouse.py
index 7f1c85138..0553f1651 100644
--- a/spp_drims/models/stock_warehouse.py
+++ b/spp_drims/models/stock_warehouse.py
@@ -31,6 +31,23 @@ class StockWarehouse(models.Model):
string="GPS Location",
help="Geographic location of the warehouse for GIS mapping",
)
+ # OP#959: warehouse tier per docs/DATA_MODEL.md:185-195
+ # central — strategic stockpile (national / regional hub)
+ # regional — provincial / district distribution centre
+ # mobile — temporary / field-deployed warehouse near the incident
+ tier = fields.Selection(
+ selection=[
+ ("central", "Central"),
+ ("regional", "Regional"),
+ ("mobile", "Mobile"),
+ ],
+ string="Warehouse Tier",
+ help=(
+ "Operational tier of this warehouse: Central (strategic stockpile), "
+ "Regional (provincial / district hub), or Mobile (field-deployed)."
+ ),
+ index=True,
+ )
# Contact
emergency_contact = fields.Char(string="Emergency Contact")
diff --git a/spp_drims/security/ir.model.access.csv b/spp_drims/security/ir.model.access.csv
index 789af72ad..d657ce6e8 100644
--- a/spp_drims/security/ir.model.access.csv
+++ b/spp_drims/security/ir.model.access.csv
@@ -75,6 +75,8 @@ access_spp_drims_bulk_approve_wizard_approver,DRIMS Bulk Approve Wizard Approver
access_spp_drims_bulk_reject_wizard_approver,DRIMS Bulk Reject Wizard Approver,model_spp_drims_bulk_reject_wizard,group_drims_approver,1,1,1,1
access_spp_drims_bulk_approve_wizard_manager,DRIMS Bulk Approve Wizard Manager,model_spp_drims_bulk_approve_wizard,group_drims_manager,1,1,1,1
access_spp_drims_bulk_reject_wizard_manager,DRIMS Bulk Reject Wizard Manager,model_spp_drims_bulk_reject_wizard,group_drims_manager,1,1,1,1
+access_spp_drims_request_reject_wizard_approver,DRIMS Request Reject Wizard Approver,model_spp_drims_request_reject_wizard,group_drims_approver,1,1,1,1
+access_spp_drims_request_reject_wizard_manager,DRIMS Request Reject Wizard Manager,model_spp_drims_request_reject_wizard,group_drims_manager,1,1,1,1
access_spp_drims_alert_sysadmin,DRIMS Alert System Admin,model_spp_drims_alert,base.group_system,1,1,1,1
access_spp_drims_alert_admin,DRIMS Alert Admin,model_spp_drims_alert,spp_security.group_spp_admin,1,1,1,1
access_spp_drims_alert_manager,DRIMS Alert Manager,model_spp_drims_alert,group_drims_manager,1,1,1,1
@@ -159,6 +161,3 @@ access_spp_drims_inspection_wizard_officer,DRIMS Inspection Wizard Officer,model
access_spp_drims_inspection_wizard_line_officer,DRIMS Inspection Wizard Line Officer,model_spp_drims_inspection_wizard_line,group_drims_officer,1,1,1,0
access_spp_drims_inspection_wizard_warehouse_staff,DRIMS Inspection Wizard Warehouse Staff,model_spp_drims_inspection_wizard,group_drims_warehouse_worker,1,1,1,0
access_spp_drims_inspection_wizard_line_warehouse_staff,DRIMS Inspection Wizard Line Warehouse Staff,model_spp_drims_inspection_wizard_line,group_drims_warehouse_worker,1,1,1,0
-access_spp_drims_inspection_item_wizard_manager,DRIMS Inspection Item Wizard Manager,model_spp_drims_inspection_item_wizard,group_drims_manager,1,1,1,1
-access_spp_drims_inspection_item_wizard_officer,DRIMS Inspection Item Wizard Officer,model_spp_drims_inspection_item_wizard,group_drims_officer,1,1,1,0
-access_spp_drims_inspection_item_wizard_warehouse_staff,DRIMS Inspection Item Wizard Warehouse Staff,model_spp_drims_inspection_item_wizard,group_drims_warehouse_worker,1,1,1,0
diff --git a/spp_drims/static/src/css/inspection_wizard.css b/spp_drims/static/src/css/inspection_wizard.css
new file mode 100644
index 000000000..bf7fe4a8a
--- /dev/null
+++ b/spp_drims/static/src/css/inspection_wizard.css
@@ -0,0 +1,106 @@
+/* Disabled-looking "All units" badge in the inspection wizard list */
+.o_drims_btn_disabled {
+ opacity: 0.38 !important;
+ cursor: not-allowed !important;
+ pointer-events: none !important;
+}
+
+/* Indent split sub-rows and prefix them with an arrow so the parent/child
+ relationship is obvious. */
+.o_drims_inspection_list .o_data_row.o_is_split td:first-child {
+ padding-left: 2rem;
+ position: relative;
+}
+.o_drims_inspection_list .o_data_row.o_is_split td:first-child::before {
+ content: "\21B3"; /* unicode rightwards arrow with hook = ↳ */
+ color: #888;
+ margin-right: 0.4rem;
+ font-weight: bold;
+}
+
+/* Left-align the Expected + Qty columns (default for floats is right-aligned,
+ but the "X / Y" progress display + per-line numbers read more naturally
+ left-aligned). */
+.o_drims_inspection_list th[data-name="quantity_expected"],
+.o_drims_inspection_list th[data-name="quantity_expected"] *,
+.o_drims_inspection_list td.o_data_cell[name="quantity_expected"],
+.o_drims_inspection_list td.o_data_cell[name="quantity_expected"] *,
+.o_drims_inspection_list td[name="quantity_expected"],
+.o_drims_inspection_list td[name="quantity_expected"] *,
+.o_drims_inspection_list th[data-name="quantity"],
+.o_drims_inspection_list th[data-name="quantity"] *,
+.o_drims_inspection_list td.o_data_cell[name="quantity"],
+.o_drims_inspection_list td.o_data_cell[name="quantity"] *,
+.o_drims_inspection_list td[name="quantity"],
+.o_drims_inspection_list td[name="quantity"] * {
+ text-align: left !important;
+ justify-content: flex-start !important;
+}
+
+/* Always show a dropdown caret on the Condition + Action cells so it's
+ obvious they're editable dropdowns even when not focused. */
+.o_drims_inspection_list td.o_data_cell[name="condition_id"]:not(.o_field_empty)::after,
+.o_drims_inspection_list
+ td.o_data_cell[name="disposition_id"]:not(.o_field_empty)::after {
+ content: "\f078"; /* fa-chevron-down */
+ font-family: "FontAwesome";
+ color: #888;
+ margin-left: 0.4rem;
+ font-size: 0.7em;
+ vertical-align: middle;
+}
+.o_drims_inspection_list td.o_data_cell[name="condition_id"],
+.o_drims_inspection_list td.o_data_cell[name="disposition_id"] {
+ padding-right: 0.4rem;
+}
+/* When the cell is empty, render a faint chevron + "Select…" hint so the
+ affordance is visible even before the operator has picked a value. */
+.o_drims_inspection_list td.o_data_cell[name="condition_id"].o_field_empty,
+.o_drims_inspection_list td.o_data_cell[name="disposition_id"].o_field_empty {
+ color: #888;
+}
+.o_drims_inspection_list td.o_data_cell[name="condition_id"].o_field_empty::after,
+.o_drims_inspection_list td.o_data_cell[name="disposition_id"].o_field_empty::after {
+ content: "Select \f078";
+ font-family: sans-serif, "FontAwesome";
+ color: #888;
+ font-size: 0.85em;
+ font-style: italic;
+}
+
+/* On split parent rows the Condition / Action cells are read-only — there's
+ no dropdown to open, so hide the chevron and the "Select" hint. */
+.o_drims_inspection_list
+ .o_data_row.o_split_parent
+ td.o_data_cell[name="condition_id"]::after,
+.o_drims_inspection_list
+ .o_data_row.o_split_parent
+ td.o_data_cell[name="disposition_id"]::after {
+ content: "" !important;
+ display: none !important;
+}
+
+/* Hide the Expected value on split child rows — the parent already shows
+ the expected total, repeating it on every child is noise. Keep the cell
+ itself so column widths don't shift. */
+.o_drims_inspection_list .o_data_row.o_is_split td[name="quantity_expected"],
+.o_drims_inspection_list .o_data_row.o_is_split td[name="quantity_expected"] * {
+ color: transparent !important;
+}
+
+/* Hide the "+ Add split" button on fully-split parent rows.
+ We piggy-back on the widget's own rendering: when the split total matches
+ the expected, the X portion is rendered with class="text-success". Use
+ :has() to select rows where that's true. This bypasses the
+ parent.quantity reactivity issue entirely. */
+.o_drims_inspection_list
+ tr:has(td[name="quantity"] span.text-success)
+ button[name="action_add_split"] {
+ display: none !important;
+}
+
+/* Hide "+ Add split" on parent rows that already have a 0-qty child
+ pending — the operator must fill the new split before opening another. */
+.o_drims_inspection_list tr.o_split_parent_has_zero button[name="action_add_split"] {
+ display: none !important;
+}
diff --git a/spp_drims/static/src/js/hide_dispatch_form_create.js b/spp_drims/static/src/js/hide_dispatch_form_create.js
new file mode 100644
index 000000000..4cfc5466f
--- /dev/null
+++ b/spp_drims/static/src/js/hide_dispatch_form_create.js
@@ -0,0 +1,34 @@
+/** @odoo-module **/
+/*
+ * OP#968 round-2 — hide the form-level "New" button when the user opens a
+ * DRIMS Dispatch picking from the dedicated action.
+ *
+ * The list view already disables create (via `view_picking_list_drims_dispatch`
+ * with `create="0"`), but Odoo 19's form view renders its own "New" button on
+ * the breadcrumb pager that the list-view attribute doesn't reach. Action
+ * context `'create': False` is unreliable in Odoo 19 too.
+ *
+ * The supported pattern in this repo is the `hideFormCreateButton` flag added
+ * by `spp_base_common/static/src/xml/custom_list_create_template.xml`. Setting
+ * it to `true` in a FormController patch makes the template skip the button.
+ *
+ * We discriminate on the model + the `default_drims_type` context key the
+ * dispatch action sets, so the standard `stock.picking` form on
+ * Inventory > Operations is unaffected.
+ */
+
+import {FormController} from "@web/views/form/form_controller";
+import {patch} from "@web/core/utils/patch";
+
+patch(FormController.prototype, {
+ setup() {
+ super.setup(...arguments);
+ const ctx = this.props.context || {};
+ if (
+ this.props.resModel === "stock.picking" &&
+ ctx.default_drims_type === "request_dispatch"
+ ) {
+ this.hideFormCreateButton = true;
+ }
+ },
+});
diff --git a/spp_drims/static/src/js/inspection_list_renderer.js b/spp_drims/static/src/js/inspection_list_renderer.js
new file mode 100644
index 000000000..885e6c73a
--- /dev/null
+++ b/spp_drims/static/src/js/inspection_list_renderer.js
@@ -0,0 +1,43 @@
+/** @odoo-module **/
+import {ListRenderer} from "@web/views/list/list_renderer";
+import {patch} from "@web/core/utils/patch";
+
+patch(ListRenderer.prototype, {
+ getRowClass(record) {
+ const base = super.getRowClass(record);
+ if (record.resModel !== "spp.drims.inspection.wizard.line") {
+ return base;
+ }
+ if (record.data.is_split) {
+ return `${base} o_is_split`;
+ }
+ if (record.data.has_splits) {
+ // Tag the parent row when any of its split children still has
+ // qty == 0. CSS uses this to hide "+ Add split" until the
+ // pending child is filled — prevents operators from stacking
+ // empty split rows.
+ const lines = (this.props.list && this.props.list.records) || [];
+ const myId = record.resId;
+ const hasZeroChild = lines.some((line) => {
+ const parentRef = line.data.parent_line_id;
+ if (!parentRef) {
+ return false;
+ }
+ const pid =
+ typeof parentRef === "object" &&
+ parentRef !== null &&
+ "id" in parentRef
+ ? parentRef.id
+ : parentRef;
+ if (pid !== myId) {
+ return false;
+ }
+ return !line.data.quantity;
+ });
+ return hasZeroChild
+ ? `${base} o_split_parent o_split_parent_has_zero`
+ : `${base} o_split_parent`;
+ }
+ return base;
+ },
+});
diff --git a/spp_drims/static/src/js/qty_split_progress_field.js b/spp_drims/static/src/js/qty_split_progress_field.js
new file mode 100644
index 000000000..ee796c05b
--- /dev/null
+++ b/spp_drims/static/src/js/qty_split_progress_field.js
@@ -0,0 +1,284 @@
+/** @odoo-module **/
+import {useEffect} from "@odoo/owl";
+import {registry} from "@web/core/registry";
+import {FloatField, floatField} from "@web/views/fields/float/float_field";
+import {formatFloat} from "@web/views/fields/formatters";
+
+/**
+ * Float widget for the inspection wizard's Qty column.
+ *
+ * On split **parent rows** (any other line points to this row via
+ * ``parent_line_id``) it renders ``X / Y``, where ``X`` is the running
+ * sum of child quantities (green when equal to the expected total ``Y``,
+ * red otherwise). Children are read live from the wizard's One2many so
+ * the display reacts to in-memory edits without relying on Python
+ * onchange propagation (which is unreliable in Odoo 19's editable-list
+ * cross-record updates).
+ *
+ * On split **child rows** the field behaves like a standard editable
+ * float; a ``useEffect`` mirrors the running sum onto the parent record
+ * via ``record.update`` so the parent's ``X / Y`` display updates
+ * reactively whenever any child's quantity changes.
+ *
+ * On plain rows it falls back to the stock ``FloatField`` behaviour.
+ */
+export class QtySplitProgressField extends FloatField {
+ static template = "spp_drims.QtySplitProgressField";
+
+ setup() {
+ super.setup();
+ useEffect(
+ () => {
+ this._syncParentQuantity();
+ this._syncWizardCanConfirm();
+ },
+ () => [
+ this.props.record.data.quantity,
+ this.props.record.data.condition_id,
+ this.props.record.data.disposition_id,
+ ]
+ );
+ }
+
+ _wizard() {
+ const record = this.props.record;
+ const candidates = [record._parentRecord, record.model && record.model.root];
+ for (const wizard of candidates) {
+ if (wizard && wizard.data && wizard.data.line_ids) {
+ return wizard;
+ }
+ }
+ return null;
+ }
+
+ async _syncWizardCanConfirm() {
+ // Three gating conditions:
+ // C1: parents with splits must have their children sum to the
+ // expected quantity.
+ // C2: every non-parent line must have both Condition and Action
+ // set.
+ // C3: no split child may have qty 0.
+ // The messages stack — if multiple conditions fail, the operator
+ // sees the union.
+ const wizard = this._wizard();
+ if (!wizard) {
+ return;
+ }
+ const lines = (wizard.data.line_ids && wizard.data.line_ids.records) || [];
+ if (lines.length === 0) {
+ return;
+ }
+
+ const childIdsByParent = new Map();
+ for (const line of lines) {
+ const parentRef = line.data.parent_line_id;
+ if (!parentRef) {
+ continue;
+ }
+ const pid = this._extractM2oId(parentRef);
+ if (pid === null) {
+ continue;
+ }
+ if (!childIdsByParent.has(pid)) {
+ childIdsByParent.set(pid, []);
+ }
+ childIdsByParent.get(pid).push(line);
+ }
+
+ let c1Failed = false;
+ let c2Failed = false;
+ let c3Failed = false;
+
+ for (const line of lines) {
+ const isParentOfSplit = Boolean(line.data.has_splits);
+ const isSplitChild = Boolean(line.data.parent_line_id);
+
+ if (isParentOfSplit) {
+ const children = childIdsByParent.get(line.resId) || [];
+ const sum = children.reduce(
+ (acc, child) => acc + (child.data.quantity || 0),
+ 0
+ );
+ const expected = line.data.quantity_expected || 0;
+ if (Math.abs(sum - expected) > 0.001) {
+ c1Failed = true;
+ }
+ } else if (!line.data.condition_id || !line.data.disposition_id) {
+ c2Failed = true;
+ }
+
+ if (isSplitChild && !line.data.quantity) {
+ c3Failed = true;
+ }
+ }
+
+ const messages = [];
+ if (c1Failed) {
+ messages.push(
+ "Split totals must match the expected quantity for each product."
+ );
+ }
+ if (c2Failed) {
+ messages.push("Set Condition and Action for every item.");
+ }
+ if (c3Failed) {
+ messages.push(
+ "Fill all split quantities — a 0-qty split is still pending."
+ );
+ }
+
+ const canConfirm = messages.length === 0;
+ const newMessage = messages.join("\n");
+ const currentCanConfirm = Boolean(wizard.data.can_confirm);
+ const currentMessage = wizard.data.confirm_message || "";
+ if (currentCanConfirm === canConfirm && currentMessage === newMessage) {
+ return;
+ }
+ await wizard.update({
+ can_confirm: canConfirm,
+ confirm_message: newMessage,
+ });
+ }
+
+ _wizardLines() {
+ const record = this.props.record;
+ const candidates = [record._parentRecord, record.model && record.model.root];
+ for (const wizard of candidates) {
+ const lineList = wizard && wizard.data && wizard.data.line_ids;
+ if (
+ lineList &&
+ Array.isArray(lineList.records) &&
+ lineList.records.length > 0
+ ) {
+ return lineList.records;
+ }
+ }
+ return [];
+ }
+
+ _extractM2oId(value) {
+ // Many2one values in Odoo 19 OWL come as { id, ... } objects (often
+ // wrapped in a Proxy). Older code paths use a [id, name] tuple.
+ // Handle both shapes plus the bare-id case.
+ if (value === null || value === undefined) {
+ return null;
+ }
+ if (typeof value === "number" || typeof value === "string") {
+ return value;
+ }
+ if (Array.isArray(value)) {
+ return value[0];
+ }
+ if (typeof value === "object" && "id" in value) {
+ return value.id;
+ }
+ return null;
+ }
+
+ _parentId() {
+ const parentRef = this.props.record.data.parent_line_id;
+ if (!parentRef) {
+ return null;
+ }
+ return this._extractM2oId(parentRef);
+ }
+
+ _myChildren() {
+ const myId = this.props.record.resId;
+ if (!myId) {
+ return [];
+ }
+ return this._wizardLines().filter((line) => {
+ const parentRef = line.data.parent_line_id;
+ if (!parentRef) {
+ return false;
+ }
+ const parentId = this._extractM2oId(parentRef);
+ return parentId === myId;
+ });
+ }
+
+ async _syncParentQuantity() {
+ // Runs after every change to this row's quantity. If the row is a
+ // split child, recompute the parent's running sum and push it onto
+ // the parent record so the parent's "X / Y" display refreshes.
+ const parentId = this._parentId();
+ if (!parentId) {
+ return;
+ }
+ const lines = this._wizardLines();
+ const parentRecord = lines.find(
+ (line) => line.resId === parentId || String(line.resId) === String(parentId)
+ );
+ if (!parentRecord) {
+ return;
+ }
+ const siblings = lines.filter((line) => {
+ const parentRef = line.data.parent_line_id;
+ if (!parentRef) {
+ return false;
+ }
+ const pid = this._extractM2oId(parentRef);
+ return pid === parentId;
+ });
+ const sum = siblings.reduce((acc, line) => acc + (line.data.quantity || 0), 0);
+ const currentParentQty = parentRecord.data.quantity || 0;
+ const expected = parentRecord.data.quantity_expected || 0;
+ const nextFullySplit = sum >= expected - 0.001;
+ const currentFullySplit = Boolean(parentRecord.data.is_fully_split);
+ const qtyChanged = Math.abs(sum - currentParentQty) >= 0.001;
+ const fullyChanged = currentFullySplit !== nextFullySplit;
+ if (!qtyChanged && !fullyChanged) {
+ return;
+ }
+ const updates = {};
+ if (qtyChanged) {
+ updates.quantity = sum;
+ }
+ if (fullyChanged) {
+ updates.is_fully_split = nextFullySplit;
+ }
+ await parentRecord.update(updates);
+ }
+
+ get hasSplits() {
+ return Boolean(this.props.record.data.has_splits);
+ }
+
+ get splitTotal() {
+ const children = this._myChildren();
+ if (children.length === 0) {
+ // Fallback to the stored parent.quantity on initial render
+ // before sibling reactivity has kicked in.
+ return this.props.record.data.quantity || 0;
+ }
+ return children.reduce((sum, line) => sum + (line.data.quantity || 0), 0);
+ }
+
+ get splitTotalFormatted() {
+ return formatFloat(this.splitTotal, {
+ digits: [16, 2],
+ trailingZeros: true,
+ });
+ }
+
+ get splitTotalClass() {
+ const expected = this.props.record.data.quantity_expected || 0;
+ const matches = Math.abs(this.splitTotal - expected) < 0.001;
+ return matches ? "text-success fw-bold" : "text-danger fw-bold";
+ }
+
+ get expectedFormatted() {
+ return formatFloat(this.props.record.data.quantity_expected || 0, {
+ digits: [16, 2],
+ trailingZeros: true,
+ });
+ }
+}
+
+export const qtySplitProgressField = {
+ ...floatField,
+ component: QtySplitProgressField,
+};
+
+registry.category("fields").add("qty_split_progress", qtySplitProgressField);
diff --git a/spp_drims/static/src/xml/qty_split_progress_field.xml b/spp_drims/static/src/xml/qty_split_progress_field.xml
new file mode 100644
index 000000000..70a0d67ee
--- /dev/null
+++ b/spp_drims/static/src/xml/qty_split_progress_field.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+
+
+
diff --git a/spp_drims/tests/test_allocation_preview_wizard.py b/spp_drims/tests/test_allocation_preview_wizard.py
index 6dfff6f81..2b27c9d45 100644
--- a/spp_drims/tests/test_allocation_preview_wizard.py
+++ b/spp_drims/tests/test_allocation_preview_wizard.py
@@ -225,6 +225,29 @@ def test_zero_stock_handling(self):
self.assertEqual(line.shortfall, 100.0)
self.assertEqual(line.allocation_status, "none")
+ def test_confirm_blocked_when_zero_stock(self):
+ """OP#1032: action_confirm_allocation refuses to advance the
+ request to allocated when the wizard's total quantity_to_allocate
+ is 0. Previously the wizard would silently confirm, set
+ quantity_allocated to 0 across all lines, and still advance the
+ request to Ready for Dispatch.
+ """
+ request = self._create_request_with_lines([(self.product, 100)])
+ initial_state = request.state
+
+ wizard = self.env["spp.drims.allocation.preview.wizard"].create(
+ {
+ "request_id": request.id,
+ "warehouse_id": self.warehouse.id,
+ }
+ )
+ wizard._populate_lines()
+ # No stock seeded — every line's quantity_to_allocate is 0.
+ with self.assertRaises(UserError):
+ wizard.action_confirm_allocation()
+ self.assertEqual(request.line_ids[0].quantity_allocated, 0)
+ self.assertEqual(request.state, initial_state)
+
def test_partial_allocation(self):
"""Test partial allocation when stock is less than requested."""
# Add partial stock
@@ -280,6 +303,56 @@ def test_full_allocation(self):
self.assertEqual(line.shortfall, 0.0)
self.assertFalse(wizard.has_shortfall)
+ def test_reallocation_subtracts_already_allocated(self):
+ """OP#1033 r2 regression: re-opening the allocation wizard after a
+ partial allocation should subtract the pending allocation from the
+ available qty — otherwise the operator can keep allocating until
+ ``quantity_allocated == quantity_requested`` while physical stock
+ has not moved.
+ """
+ # Warehouse has 1000 units; request is for 5000.
+ self.env["stock.quant"].create(
+ {
+ "product_id": self.stockable_product.id,
+ "location_id": self.warehouse.lot_stock_id.id,
+ "quantity": 1000.0,
+ }
+ )
+ request = self._create_request_with_lines([(self.stockable_product, 5000)])
+
+ # First allocation — uses up the 1000 physical units.
+ wizard_1 = self.env["spp.drims.allocation.preview.wizard"].create(
+ {
+ "request_id": request.id,
+ "warehouse_id": self.warehouse.id,
+ }
+ )
+ wizard_1._populate_lines()
+ self.assertEqual(wizard_1.line_ids[0].available_qty, 1000.0)
+ self.assertEqual(wizard_1.line_ids[0].quantity_to_allocate, 1000.0)
+ wizard_1.action_confirm_allocation()
+ self.assertEqual(request.line_ids[0].quantity_allocated, 1000.0)
+
+ # Re-open the wizard without any dispatch happening. The shortfall
+ # should remain 4000 and available should now report 0, NOT another
+ # 1000 (the physical stock is still in place but it's already
+ # committed to this request).
+ wizard_2 = self.env["spp.drims.allocation.preview.wizard"].create(
+ {
+ "request_id": request.id,
+ "warehouse_id": self.warehouse.id,
+ }
+ )
+ wizard_2._populate_lines()
+ self.assertEqual(wizard_2.line_ids[0].available_qty, 0.0)
+ self.assertEqual(wizard_2.line_ids[0].quantity_to_allocate, 0.0)
+ self.assertEqual(wizard_2.line_ids[0].shortfall, 4000.0)
+ # Confirming with nothing to allocate must raise.
+ with self.assertRaises(UserError):
+ wizard_2.action_confirm_allocation()
+ # request.quantity_allocated must NOT have been bumped up.
+ self.assertEqual(request.line_ids[0].quantity_allocated, 1000.0)
+
def test_empty_allocation_error(self):
"""Test that confirming allocation with no items raises error."""
request = self._create_request_with_lines([(self.stockable_product, 100)])
diff --git a/spp_drims/tests/test_donation.py b/spp_drims/tests/test_donation.py
index 15bf5c7cf..1dd4b7a56 100644
--- a/spp_drims/tests/test_donation.py
+++ b/spp_drims/tests/test_donation.py
@@ -668,3 +668,416 @@ def test_donation_cannot_cancel_rejected(self):
# Should fail
with self.assertRaises(UserError):
donation.action_cancel()
+
+ # ---------- OP#961: lot/serial assignment on action_stock ----------
+
+ def _make_tracked_product(self, tracking, name="Tracked Item"):
+ return self.env["product.product"].create(
+ {
+ "name": name,
+ "type": "consu",
+ "is_storable": True,
+ "tracking": tracking,
+ "standard_price": 50.0,
+ }
+ )
+
+ def _make_donation(self, line_vals):
+ return self.env["spp.drims.donation"].create(
+ {
+ "incident_id": self.incident.id,
+ "warehouse_id": self.warehouse.id,
+ "donor_name": "Test Donor",
+ "line_ids": [(0, 0, vals) for vals in line_vals],
+ }
+ )
+
+ def test_action_stock_creates_lot_for_lot_tracked_product(self):
+ """Lot-tracked product validates and a stock.lot is created from lot_number."""
+ product = self._make_tracked_product("lot", "Rice 25kg (lot)")
+ donation = self._make_donation(
+ [
+ {
+ "product_id": product.id,
+ "quantity_pledged": 10,
+ "uom_id": product.uom_id.id,
+ "lot_number": "LOT-RICE-001",
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.action_stock()
+ self.assertEqual(donation.state, "stocked")
+ lot = self.env["stock.lot"].search(
+ [("name", "=", "LOT-RICE-001"), ("product_id", "=", product.id)],
+ limit=1,
+ )
+ self.assertTrue(lot, "expected a stock.lot named LOT-RICE-001 to be created")
+
+ def test_action_stock_sets_expiry_when_provided(self):
+ """expiry_date on the donation line propagates to stock.lot.expiration_date."""
+ if "expiration_date" not in self.env["stock.lot"]._fields:
+ self.skipTest("product_expiry module not installed")
+ product = self._make_tracked_product("lot", "Vaccine (lot)")
+ expiry = date.today() + timedelta(days=180)
+ donation = self._make_donation(
+ [
+ {
+ "product_id": product.id,
+ "quantity_pledged": 5,
+ "uom_id": product.uom_id.id,
+ "lot_number": "LOT-VAC-2026",
+ "expiry_date": expiry,
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.action_stock()
+ lot = self.env["stock.lot"].search(
+ [("name", "=", "LOT-VAC-2026"), ("product_id", "=", product.id)],
+ limit=1,
+ )
+ self.assertTrue(lot)
+ # expiration_date may be Date or Datetime depending on product_expiry version
+ stored = lot.expiration_date
+ if hasattr(stored, "date"):
+ stored = stored.date()
+ self.assertEqual(stored, expiry)
+
+ def test_action_stock_reuses_existing_lot(self):
+ """A stock.lot with the same name + product is reused, not duplicated."""
+ product = self._make_tracked_product("lot", "Rice 25kg (existing lot)")
+ existing = self.env["stock.lot"].create(
+ {
+ "name": "LOT-RICE-EXISTING",
+ "product_id": product.id,
+ "company_id": self.env.company.id,
+ }
+ )
+ donation = self._make_donation(
+ [
+ {
+ "product_id": product.id,
+ "quantity_pledged": 3,
+ "uom_id": product.uom_id.id,
+ "lot_number": "LOT-RICE-EXISTING",
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.action_stock()
+ lots = self.env["stock.lot"].search([("name", "=", "LOT-RICE-EXISTING"), ("product_id", "=", product.id)])
+ self.assertEqual(len(lots), 1)
+ self.assertEqual(lots, existing)
+
+ def test_action_stock_serial_qty_one_succeeds(self):
+ """Serial-tracked product with quantity 1 and lot_number validates."""
+ product = self._make_tracked_product("serial", "Generator (serial)")
+ donation = self._make_donation(
+ [
+ {
+ "product_id": product.id,
+ "quantity_pledged": 1,
+ "uom_id": product.uom_id.id,
+ "lot_number": "SN-GEN-001",
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.action_stock()
+ self.assertEqual(donation.state, "stocked")
+
+ def test_action_stock_serial_qty_gt_one_raises(self):
+ """Serial product with quantity > 1 raises UserError (one serial per unit)."""
+ product = self._make_tracked_product("serial", "Generator multi")
+ donation = self._make_donation(
+ [
+ {
+ "product_id": product.id,
+ "quantity_pledged": 3,
+ "uom_id": product.uom_id.id,
+ "lot_number": "SN-GEN-002",
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ with self.assertRaises(UserError):
+ donation.action_stock()
+
+ def test_action_stock_missing_lot_number_raises(self):
+ """Tracked product without lot_number raises a friendly UserError."""
+ product = self._make_tracked_product("lot", "Rice missing lot")
+ donation = self._make_donation(
+ [
+ {
+ "product_id": product.id,
+ "quantity_pledged": 2,
+ "uom_id": product.uom_id.id,
+ # lot_number intentionally omitted
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ with self.assertRaises(UserError):
+ donation.action_stock()
+
+ def test_action_stock_untracked_product_unaffected(self):
+ """Untracked products continue to validate without any lot handling."""
+ # self.product has tracking='none' by default
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 25,
+ "uom_id": self.product.uom_id.id,
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.action_stock()
+ self.assertEqual(donation.state, "stocked")
+
+ # ---------- OP#1030: non-accept dispositions excluded from stocking ----------
+
+ def _disposition(self, code):
+ return self.env["spp.vocabulary.code"].search(
+ [
+ ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:item-dispositions"),
+ ("code", "=", code),
+ ],
+ limit=1,
+ )
+
+ def _qty_in_warehouse(self, product, warehouse):
+ return sum(
+ self.env["stock.quant"]
+ .search(
+ [
+ ("product_id", "=", product.id),
+ ("location_id", "child_of", warehouse.lot_stock_id.id),
+ ]
+ )
+ .mapped("quantity")
+ )
+
+ def test_action_stock_excludes_return_disposition(self):
+ """OP#1030: lines with disposition=return are cancelled, not stocked."""
+ disposition_return = self._disposition("return")
+ if not disposition_return:
+ self.skipTest("return disposition vocab code missing")
+
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 200,
+ "uom_id": self.product.uom_id.id,
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disposition_return
+
+ result = donation.action_stock()
+ self.assertEqual(donation.state, "stocked")
+ # Nothing should land in the warehouse.
+ self.assertEqual(self._qty_in_warehouse(self.product, self.warehouse), 0.0)
+ # The picking should end up cancelled because every move was excluded.
+ for picking in donation.picking_ids:
+ self.assertEqual(picking.state, "cancel")
+ # A user-visible warning is returned.
+ self.assertEqual(result["type"], "ir.actions.client")
+ self.assertEqual(result["tag"], "display_notification")
+ self.assertIn("excluded", result["params"]["message"].lower())
+
+ def test_action_stock_excludes_dispose_disposition(self):
+ """OP#1030: lines with disposition=dispose are cancelled too."""
+ disposition_dispose = self._disposition("dispose")
+ if not disposition_dispose:
+ self.skipTest("dispose disposition vocab code missing")
+
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 100,
+ "uom_id": self.product.uom_id.id,
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disposition_dispose
+
+ donation.action_stock()
+ self.assertEqual(self._qty_in_warehouse(self.product, self.warehouse), 0.0)
+
+ def test_action_stock_mixed_dispositions_only_accepted_stocks(self):
+ """OP#1030: in a mixed donation, only accepted lines reach the warehouse."""
+ disposition_accept = self._disposition("accept")
+ disposition_return = self._disposition("return")
+ if not (disposition_accept and disposition_return):
+ self.skipTest("required disposition codes missing")
+
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 800,
+ "uom_id": self.product.uom_id.id,
+ },
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 200,
+ "uom_id": self.product.uom_id.id,
+ },
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disposition_accept
+ donation.line_ids[1].disposition_id = disposition_return
+
+ result = donation.action_stock()
+ self.assertEqual(donation.state, "stocked")
+ # Only the 800 accepted units land in the warehouse.
+ self.assertEqual(self._qty_in_warehouse(self.product, self.warehouse), 800.0)
+ # Warning lists the 200 excluded units.
+ self.assertIsNotNone(result)
+ self.assertIn("200", result["params"]["message"])
+
+ def test_action_stock_mixed_dispositions_partial_receive_only_stocks_accept(self):
+ """OP#1030 regression: even when Odoo merges the receipt moves and
+ when received qty differs from pledged, only the accepted received
+ quantity should land in the warehouse.
+
+ Reproduces the bug screenshot scenario:
+ - Donation has 2 lines of the same product
+ - Line 1: pledged 500, received 200, Accept
+ - Line 2: pledged 300, received 300, Return to Donor
+ - Expected: only 200 (Accept line's received qty) reaches the warehouse.
+ """
+ disposition_accept = self._disposition("accept")
+ disposition_return = self._disposition("return")
+ if not (disposition_accept and disposition_return):
+ self.skipTest("required disposition codes missing")
+
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 500,
+ "uom_id": self.product.uom_id.id,
+ },
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 300,
+ "uom_id": self.product.uom_id.id,
+ },
+ ]
+ )
+ donation.action_mark_received()
+ # Simulate the OP#964 scenario: line 1's received is reduced after
+ # receipt (e.g. the actual delivery was short of the pledged amount).
+ donation.line_ids[0].quantity_received = 200
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disposition_accept
+ donation.line_ids[1].disposition_id = disposition_return
+
+ result = donation.action_stock()
+ self.assertEqual(donation.state, "stocked")
+ self.assertEqual(self._qty_in_warehouse(self.product, self.warehouse), 200.0)
+ self.assertIsNotNone(result)
+ self.assertIn("300", result["params"]["message"])
+
+ def test_has_acceptable_items_all_non_accept(self):
+ """When every line is non-accept (return / dispose / quarantine) the
+ ``has_acceptable_items`` flag drops to False so the form hides the
+ Stock button and surfaces the "Nothing to Stock" info alert."""
+ disposition_return = self._disposition("return")
+ disposition_dispose = self._disposition("dispose")
+ if not (disposition_return and disposition_dispose):
+ self.skipTest("non-accept disposition vocab codes missing")
+
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 100,
+ "uom_id": self.product.uom_id.id,
+ },
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 100,
+ "uom_id": self.product.uom_id.id,
+ },
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disposition_return
+ donation.line_ids[1].disposition_id = disposition_dispose
+
+ self.assertFalse(
+ donation.has_acceptable_items,
+ "every line is non-accept — Stock button should be hidden",
+ )
+
+ def test_has_acceptable_items_mixed(self):
+ """One accept line is enough to keep Stock available."""
+ disposition_accept = self._disposition("accept")
+ disposition_return = self._disposition("return")
+ if not (disposition_accept and disposition_return):
+ self.skipTest("required disposition codes missing")
+
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 100,
+ "uom_id": self.product.uom_id.id,
+ },
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 50,
+ "uom_id": self.product.uom_id.id,
+ },
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disposition_accept
+ donation.line_ids[1].disposition_id = disposition_return
+
+ self.assertTrue(donation.has_acceptable_items)
+
+ def test_action_stock_all_accept_unchanged(self):
+ """OP#1030: regression — full-accept flow still stocks everything."""
+ disposition_accept = self._disposition("accept")
+ if not disposition_accept:
+ self.skipTest("accept disposition vocab code missing")
+
+ donation = self._make_donation(
+ [
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 500,
+ "uom_id": self.product.uom_id.id,
+ }
+ ]
+ )
+ donation.action_mark_received()
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disposition_accept
+
+ result = donation.action_stock()
+ self.assertIsNone(result, "no excluded units → no notification")
+ self.assertEqual(self._qty_in_warehouse(self.product, self.warehouse), 500.0)
diff --git a/spp_drims/tests/test_request.py b/spp_drims/tests/test_request.py
index 7bc5de45a..859c763cb 100644
--- a/spp_drims/tests/test_request.py
+++ b/spp_drims/tests/test_request.py
@@ -368,7 +368,11 @@ def test_allocate_without_warehouse(self):
request.action_allocate()
def test_allocate_with_warehouse(self):
- """Test allocation workflow with source warehouse (GAP-REQ-001/002)."""
+ """Test allocation workflow with source warehouse (GAP-REQ-001/002).
+
+ OP#1032: the warehouse must also have stock — without stock,
+ `action_allocate` now refuses to advance the request state.
+ """
request = self.env["spp.drims.request"].create(
{
"incident_id": self.incident.id,
@@ -390,9 +394,51 @@ def test_allocate_with_warehouse(self):
)
request.action_submit()
request.action_approve()
- # Now allocation should work
+ # Seed stock so the allocation actually has something to grab.
+ self.env["stock.quant"].create(
+ {
+ "product_id": self.product.id,
+ "location_id": self.warehouse.lot_stock_id.id,
+ "quantity": 25.0,
+ }
+ )
request.action_allocate()
self.assertEqual(request.state, "allocated")
+ self.assertGreater(request.total_allocated, 0)
+
+ def test_allocate_blocked_when_warehouse_has_no_stock(self):
+ """OP#1032: action_allocate refuses to advance the request state
+ when the source warehouse has zero available stock for every
+ requested line. Previously the request silently advanced to
+ Ready for Dispatch with 0 allocated.
+ """
+ request = self.env["spp.drims.request"].create(
+ {
+ "incident_id": self.incident.id,
+ "destination_area_id": self.area.id,
+ "date_needed": self.future_date,
+ "source_warehouse_id": self.warehouse.id,
+ "line_ids": [
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product.id,
+ "quantity_requested": 10,
+ "uom_id": self.product.uom_id.id,
+ },
+ )
+ ],
+ }
+ )
+ request.action_submit()
+ request.action_approve()
+ # No stock seeded — allocation should raise and the state should
+ # stay at approved (Ready for Allocation).
+ with self.assertRaises(UserError):
+ request.action_allocate()
+ self.assertEqual(request.state, "approved")
+ self.assertEqual(request.total_allocated, 0)
def test_create_dispatch_not_allocated(self):
"""Test that dispatch requires allocated state (GAP-REQ-003)."""
@@ -516,3 +562,92 @@ def test_create_dispatch_no_allocated_lines(self):
# No lines allocated - should fail
with self.assertRaises(UserError):
request.action_create_dispatch()
+
+ # ---------- OP#1033: partial dispatches ----------
+
+ def _setup_allocated_request(self, requested=5000, allocated=2000):
+ """Build a request in ``allocated`` state with the given line numbers."""
+ request = self.env["spp.drims.request"].create(
+ {
+ "incident_id": self.incident.id,
+ "destination_area_id": self.area.id,
+ "date_needed": self.future_date,
+ "source_warehouse_id": self.warehouse.id,
+ "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()
+ request.line_ids[0].quantity_allocated = allocated
+ allocated_state = self.env["spp.vocabulary.code"].search(
+ [
+ ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:request-states"),
+ ("code", "=", "allocated"),
+ ],
+ limit=1,
+ )
+ if allocated_state:
+ request.state_id = allocated_state
+ return request
+
+ def test_partial_dispatch_keeps_state_allocated(self):
+ """OP#1033: dispatching less than requested stays at allocated."""
+ request = self._setup_allocated_request(requested=5000, allocated=2000)
+
+ request.action_create_dispatch()
+
+ self.assertEqual(request.state, "allocated")
+ self.assertEqual(request.line_ids[0].quantity_dispatched, 2000)
+ self.assertEqual(request.picking_count, 1)
+
+ def test_second_dispatch_after_top_up_advances_to_dispatched(self):
+ """OP#1033: a second Create Dispatch after extra allocation
+ creates a new picking and flips the state to dispatched.
+ """
+ request = self._setup_allocated_request(requested=5000, allocated=2000)
+ request.action_create_dispatch()
+ self.assertEqual(request.state, "allocated")
+
+ # Operator allocates the remaining 3000 and dispatches again.
+ request.line_ids[0].quantity_allocated = 5000
+ request.action_create_dispatch()
+
+ self.assertEqual(request.state, "dispatched")
+ self.assertEqual(request.line_ids[0].quantity_dispatched, 5000)
+ self.assertEqual(request.picking_count, 2)
+ # Both pickings remain linked to the request.
+ for picking in request.picking_ids:
+ self.assertEqual(picking.drims_request_id, request)
+
+ def test_second_dispatch_picking_only_covers_remainder(self):
+ """OP#1033: the second picking moves only the newly-allocated qty."""
+ request = self._setup_allocated_request(requested=5000, allocated=2000)
+ request.action_create_dispatch()
+ first_picking = request.picking_ids[0]
+ self.assertEqual(first_picking.move_ids[0].product_uom_qty, 2000)
+
+ request.line_ids[0].quantity_allocated = 5000
+ request.action_create_dispatch()
+ second_picking = request.picking_ids - first_picking
+ self.assertEqual(len(second_picking), 1)
+ self.assertEqual(second_picking.move_ids[0].product_uom_qty, 3000)
+
+ def test_dispatch_blocked_when_nothing_remaining(self):
+ """OP#1033: clicking Create Dispatch with no remainder raises."""
+ request = self._setup_allocated_request(requested=5000, allocated=2000)
+ request.action_create_dispatch()
+
+ # No further allocation happened — the second call has nothing to do.
+ with self.assertRaises(UserError) as cm:
+ request.action_create_dispatch()
+ self.assertIn("Nothing left to dispatch", str(cm.exception))
diff --git a/spp_drims/tests/test_wizard.py b/spp_drims/tests/test_wizard.py
index cb60da68e..38c308411 100644
--- a/spp_drims/tests/test_wizard.py
+++ b/spp_drims/tests/test_wizard.py
@@ -133,6 +133,68 @@ def test_bulk_approve_to_reject_flow(self):
self.assertEqual(action["target"], "new")
self.assertIn("default_request_ids", action["context"])
+ # ---------- OP#966: single-record reject wizard ----------
+
+ def test_action_open_reject_wizard_returns_act_window(self):
+ """OP#966: action_open_reject_wizard returns the wizard action."""
+ request = self._create_pending_request()
+ action = request.action_open_reject_wizard()
+ self.assertEqual(action["type"], "ir.actions.act_window")
+ self.assertEqual(action["res_model"], "spp.drims.request.reject.wizard")
+ self.assertEqual(action["target"], "new")
+ self.assertEqual(action["context"]["default_request_id"], request.id)
+
+ def test_action_open_reject_wizard_only_pending(self):
+ """OP#966: opening the wizard on a non-pending request raises."""
+ 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": 10,
+ "uom_id": self.product.uom_id.id,
+ },
+ )
+ ],
+ }
+ )
+ # Still in draft, never submitted
+ with self.assertRaises(UserError):
+ request.action_open_reject_wizard()
+
+ def test_request_reject_wizard_writes_reason_and_rejects(self):
+ """OP#966: the wizard writes rejection_reason and rejects the request."""
+ request = self._create_pending_request()
+ wizard = self.env["spp.drims.request.reject.wizard"].create(
+ {
+ "request_id": request.id,
+ "reason": "Out of scope for this funding cycle",
+ }
+ )
+ wizard.action_reject()
+ self.assertEqual(request.approval_state, "rejected")
+ self.assertEqual(request.rejection_reason, "Out of scope for this funding cycle")
+
+ def test_request_reject_wizard_blank_reason_raises(self):
+ """OP#966: whitespace-only reason raises UserError."""
+ request = self._create_pending_request()
+ wizard = self.env["spp.drims.request.reject.wizard"].create(
+ {
+ "request_id": request.id,
+ "reason": " ",
+ }
+ )
+ with self.assertRaises(UserError):
+ wizard.action_reject()
+ # Request stays pending
+ self.assertEqual(request.approval_state, "pending")
+
@tagged("post_install", "-at_install")
class TestInspectionWizard(DrimsTestCommon):
@@ -226,161 +288,150 @@ def _open_inspection_wizard(self, donation):
wizard_id = action["res_id"]
return self.env["spp.drims.inspection.wizard"].browse(wizard_id)
- def test_inspection_wizard_defaults_to_accepted(self):
- """Test wizard pre-creates lines with New/Accept defaults."""
- if not self.condition_new or not self.disposition_accept:
- self.skipTest("Required vocabulary codes not found")
-
+ def _set_inspection(self, line, condition=None, disposition=None):
+ """Helper: write Condition + Action; ``is_inspected`` is computed."""
+ vals = {}
+ if condition is not None:
+ vals["condition_id"] = condition.id
+ if disposition is not None:
+ vals["disposition_id"] = disposition.id
+ line.write(vals)
+
+ def test_inspection_wizard_no_pre_fill(self):
+ """OP#963: wizard lines open blank — operator must set both fields."""
donation = self._create_received_donation(quantity=1000)
wizard = self._open_inspection_wizard(donation)
- # Lines should be pre-created with defaults (New/Accept, already inspected)
self.assertEqual(len(wizard.line_ids), 1)
- self.assertTrue(wizard.line_ids[0].is_inspected)
- self.assertEqual(wizard.line_ids[0].condition_id, self.condition_new)
- self.assertEqual(wizard.line_ids[0].disposition_id, self.disposition_accept)
- self.assertTrue(wizard.is_valid)
-
- # Can confirm immediately without any additional action
- wizard.action_confirm_inspection()
- self.assertEqual(donation.state, "inspected")
- self.assertEqual(donation.line_ids[0].condition_id, self.condition_new)
-
- def test_inspection_wizard_edit_item(self):
- """Test Edit button opens popup wizard for individual item."""
- donation = self._create_received_donation(quantity=500)
- wizard = self._open_inspection_wizard(donation)
-
- # Click Edit on first line
line = wizard.line_ids[0]
- action = line.action_edit_item()
-
- self.assertEqual(action["res_model"], "spp.drims.inspection.item.wizard")
- self.assertEqual(action["target"], "new")
- self.assertEqual(action["context"]["default_quantity"], 500)
- self.assertEqual(action["context"]["default_quantity_expected"], 500)
+ self.assertFalse(line.condition_id)
+ self.assertFalse(line.disposition_id)
+ self.assertFalse(line.is_inspected)
+ with self.assertRaises(UserError):
+ wizard.action_confirm_inspection()
- def test_inspection_item_wizard_save(self):
- """Test item popup Save action updates the main wizard line."""
- if not self.condition_damaged or not self.disposition_return:
+ def test_is_inspected_tracks_both_fields(self):
+ """OP#963: is_inspected is True only when both Condition and Action are set."""
+ if not self.condition_new or not self.disposition_accept:
self.skipTest("Required vocabulary codes not found")
donation = self._create_received_donation(quantity=100)
wizard = self._open_inspection_wizard(donation)
-
line = wizard.line_ids[0]
- # Line starts inspected with defaults, we change it to damaged
- self.assertTrue(line.is_inspected)
- # Create item wizard and save with different condition
- item_wizard = self.env["spp.drims.inspection.item.wizard"].create(
- {
- "inspection_line_id": line.id,
- "wizard_id": wizard.id,
- "product_id": self.product.id,
- "uom_id": self.product.uom_id.id,
- "quantity_expected": 100,
- "quantity": 100,
- "condition_id": self.condition_damaged.id,
- "disposition_id": self.disposition_return.id,
- "notes": "All items damaged",
- }
- )
- item_wizard.action_save()
+ line.condition_id = self.condition_new
+ self.assertFalse(line.is_inspected, "needs both fields")
- # Line should be updated with new condition
+ line.disposition_id = self.disposition_accept
self.assertTrue(line.is_inspected)
- self.assertEqual(line.condition_id, self.condition_damaged)
- self.assertEqual(line.disposition_id, self.disposition_return)
- self.assertEqual(line.notes, "All items damaged")
- def test_inspection_item_wizard_split(self):
- """Test Save & Add Split creates a new line for remaining quantity."""
+ def test_can_mark_all_units_toggles_with_both_fields(self):
+ """OP#963: badge enable/disable mirrors condition+action presence."""
if not self.condition_new or not self.disposition_accept:
self.skipTest("Required vocabulary codes not found")
- donation = self._create_received_donation(quantity=1000)
+ donation = self._create_received_donation(quantity=100)
wizard = self._open_inspection_wizard(donation)
-
line = wizard.line_ids[0]
- self.assertEqual(len(wizard.line_ids), 1)
- # Create item wizard with reduced quantity and split
- item_wizard = self.env["spp.drims.inspection.item.wizard"].create(
- {
- "inspection_line_id": line.id,
- "wizard_id": wizard.id,
- "product_id": self.product.id,
- "uom_id": self.product.uom_id.id,
- "quantity_expected": 1000,
- "quantity": 800, # Only 800, remaining 200
- "condition_id": self.condition_new.id,
- "disposition_id": self.disposition_accept.id,
- }
- )
+ self.assertFalse(line.can_mark_all_units)
+ line.condition_id = self.condition_new
+ self.assertFalse(line.can_mark_all_units)
+ line.disposition_id = self.disposition_accept
+ self.assertTrue(line.can_mark_all_units)
- # Should show remaining qty
- self.assertEqual(item_wizard.remaining_qty, 200)
- self.assertTrue(item_wizard.show_split_warning)
+ def test_inspection_confirm_full_acceptance(self):
+ """OP#963: filling Condition + Action on every row makes the wizard
+ valid and confirming writes the chosen values to the donation lines.
+ """
+ if not self.condition_new or not self.disposition_accept:
+ self.skipTest("Required vocabulary codes not found")
- # Save and split
- action = item_wizard.action_save_and_split()
+ donation = self._create_received_donation(quantity=1000)
+ wizard = self._open_inspection_wizard(donation)
+ line = wizard.line_ids[0]
- # Should have 2 lines now
- self.assertEqual(len(wizard.line_ids), 2)
- # First line is inspected with 800
- self.assertTrue(line.is_inspected)
- self.assertEqual(line.quantity, 800)
- # New line has remaining 200, not yet inspected
- new_line = wizard.line_ids.filtered(lambda ln: ln.id != line.id)
- self.assertEqual(new_line.quantity, 200)
- self.assertFalse(new_line.is_inspected)
+ self._set_inspection(line, self.condition_new, self.disposition_accept)
- # Action should open popup for new line
- self.assertEqual(action["res_model"], "spp.drims.inspection.item.wizard")
+ wizard.action_confirm_inspection()
+ self.assertEqual(donation.state, "inspected")
+ self.assertEqual(donation.line_ids[0].condition_id, self.condition_new)
+ self.assertEqual(donation.line_ids[0].disposition_id, self.disposition_accept)
def test_inspection_wizard_validation_uninspected(self):
- """Test validation fails if items are not inspected."""
+ """Confirm refuses to advance when any row is missing its decision."""
donation = self._create_received_donation(quantity=100)
wizard = self._open_inspection_wizard(donation)
- # Lines start as inspected, manually mark as not inspected
- wizard.line_ids[0].write({"is_inspected": False})
-
- # Not inspected
- self.assertFalse(wizard.is_valid)
-
- # Should fail confirmation
with self.assertRaises(UserError) as cm:
wizard.action_confirm_inspection()
self.assertIn("inspect all items", str(cm.exception))
- def test_inspection_wizard_validation_quantity_mismatch(self):
- """Test validation fails if quantities don't match."""
+ def test_inspection_wizard_single_line_quantity_overrides_received(self):
+ """OP#964: a single inspection line is treated as the user reporting
+ the final received quantity.
+ """
if not self.condition_new or not self.disposition_accept:
self.skipTest("Required vocabulary codes not found")
donation = self._create_received_donation(quantity=1000)
wizard = self._open_inspection_wizard(donation)
+ line = wizard.line_ids[0]
+ self._set_inspection(line, self.condition_new, self.disposition_accept)
+ line.quantity = 800
- # Manually change quantity to create mismatch
- wizard.line_ids[0].write(
- {
- "quantity": 800, # Less than expected 1000
- }
- )
+ wizard.action_confirm_inspection()
+ self.assertEqual(donation.state, "inspected")
+ self.assertEqual(donation.line_ids[0].quantity_received, 800)
- # is_valid should be False
- self.assertFalse(wizard.is_valid)
+ def test_inspection_wizard_split_mismatch_still_raises(self):
+ """OP#964: split quantities must still sum to expected."""
+ if not self.condition_new or not self.condition_damaged:
+ self.skipTest("Required vocabulary codes not found")
+ if not self.disposition_accept or not self.disposition_return:
+ self.skipTest("Required vocabulary codes not found")
+
+ donation = self._create_received_donation(quantity=1000)
+ wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
+
+ # Create a split via the real button so parent/child wiring is correct.
+ # The first split divides the row into two empty children.
+ parent.action_add_split()
+ children = wizard.line_ids.filtered("is_split").sorted(key=lambda line: line.id)
+ self.assertEqual(len(children), 2)
+ child_a, child_b = children[0], children[1]
+
+ # Deliberately make the totals not match expected (800 + 150 = 950 ≠ 1000).
+ child_a.quantity = 800
+ child_b.quantity = 150
+ self._set_inspection(child_a, self.condition_new, self.disposition_accept)
+ self._set_inspection(child_b, self.condition_damaged, self.disposition_return)
- # Should fail confirmation
with self.assertRaises(UserError) as cm:
wizard.action_confirm_inspection()
- self.assertIn("must equal", str(cm.exception))
+ self.assertIn("Splits must sum", str(cm.exception))
+
+ def test_inspection_wizard_quantity_received_zero_uses_pledged(self):
+ """OP#964: fall back to quantity_pledged when quantity_received is 0."""
+ if not self.condition_new or not self.disposition_accept:
+ self.skipTest("Required vocabulary codes not found")
+
+ donation = self._create_received_donation(quantity=500)
+ donation.line_ids[0].quantity_received = 0
+
+ wizard = self._open_inspection_wizard(donation)
+ line = wizard.line_ids[0]
+ self.assertEqual(line.quantity_expected, 500)
+
+ self._set_inspection(line, self.condition_new, self.disposition_accept)
+
+ wizard.action_confirm_inspection()
+ self.assertEqual(donation.state, "inspected")
+ self.assertEqual(donation.line_ids[0].quantity_received, 500)
def test_inspection_wizard_only_received_donations(self):
"""Test that only received donations can be inspected."""
- # Create a donation without marking as received
donation = self.env["spp.drims.donation"].create(
{
"incident_id": self.incident.id,
@@ -398,73 +449,153 @@ def test_inspection_wizard_only_received_donations(self):
],
}
)
-
- # Should raise error when trying to open inspection wizard
with self.assertRaises(UserError):
donation.action_open_inspection_wizard()
- def test_inspection_wizard_condition_display(self):
- """Test condition_display computed field."""
- if not self.condition_new or not self.disposition_accept:
- self.skipTest("Required vocabulary codes not found")
+ def test_add_split_creates_two_child_lines(self):
+ """OP#963: the first action_add_split divides the row into two child
+ splits (parent + 2 children), each with parent_line_id set and starting
+ at qty=0 (the operator must fill them explicitly)."""
+ donation = self._create_received_donation(quantity=1000)
+ wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
+
+ parent.action_add_split()
+ self.assertEqual(len(wizard.line_ids), 3)
+
+ children = wizard.line_ids.filtered("is_split")
+ self.assertEqual(len(children), 2)
+ for child in children:
+ self.assertEqual(child.parent_line_id, parent)
+ # New split children are created with qty 0 — the operator fills
+ # the rows themselves.
+ self.assertEqual(child.quantity, 0)
+ # Parent qty mirrors the child running total (also 0).
+ self.assertEqual(parent.quantity, 0)
+ self.assertTrue(parent.has_splits)
+ # Parent is not yet "fully split" until the children are filled.
+ self.assertFalse(parent.is_fully_split)
+
+ def test_add_split_subsequent_splits_are_also_zero(self):
+ """OP#963: every new split starts at qty=0; the running parent total
+ stays at the existing children sum.
+ """
+ donation = self._create_received_donation(quantity=1000)
+ wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
+
+ parent.action_add_split() # first split -> two children
+ children = wizard.line_ids.filtered("is_split")
+ children[0].quantity = 700
+
+ parent.action_add_split() # subsequent split -> one more child
+ children = wizard.line_ids.filtered("is_split")
+ self.assertEqual(len(children), 3)
+ new_child = children.sorted(key=lambda line: line.id)[-1]
+ # New child starts at 0; running parent total stays at 700.
+ self.assertEqual(new_child.quantity, 0)
+
+ def test_add_split_blocks_when_nothing_remaining(self):
+ """OP#963: once children already cover the full expected qty,
+ further adds raise (defence-in-depth — the UI hides the button
+ whenever the running total matches expected).
+ """
+ donation = self._create_received_donation(quantity=1000)
+ wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
- donation = self._create_received_donation(quantity=100)
+ parent.action_add_split()
+ children = wizard.line_ids.filtered("is_split")
+ # Manually fill the child so the running total reaches expected.
+ children[0].quantity = 1000
+ with self.assertRaises(UserError) as cm:
+ parent.action_add_split()
+ self.assertIn("No remaining quantity to split", str(cm.exception))
+
+ def test_remove_split_resets_parent_qty(self):
+ """OP#963: removing the last child resets parent.quantity to expected."""
+ donation = self._create_received_donation(quantity=1000)
wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
- line = wizard.line_ids[0]
+ parent.action_add_split()
+ children = wizard.line_ids.filtered("is_split")
+ self.assertEqual(len(children), 2)
- # Starts inspected with defaults
- self.assertIn(self.condition_new.display, line.condition_display)
- self.assertIn(self.disposition_accept.display, line.condition_display)
+ # Removing one child leaves the parent split with the other.
+ children[0].action_remove_split()
+ remaining = wizard.line_ids.filtered("is_split")
+ self.assertEqual(len(remaining), 1)
+ self.assertTrue(parent.has_splits)
- # After marking as not inspected
- line.write({"is_inspected": False})
- self.assertEqual(line.condition_display, "Not inspected")
+ # Removing the last child reverts the parent to a normal row.
+ remaining.action_remove_split()
+ self.assertEqual(len(wizard.line_ids), 1)
+ self.assertEqual(parent.quantity, 1000)
+ self.assertFalse(parent.has_splits)
- def test_inspection_confirms_and_creates_splits(self):
- """Test full flow: inspect with splits and confirm."""
+ def test_has_splits_true_when_child_exists(self):
+ """OP#963: ``has_splits`` flips True on the parent once a child exists."""
+ donation = self._create_received_donation(quantity=500)
+ wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
+
+ self.assertFalse(parent.has_splits)
+ parent.action_add_split()
+ self.assertTrue(parent.has_splits)
+
+ def test_confirm_ignores_parent_row_when_splits_exist(self):
+ """OP#963: the parent row carries no condition/action — Confirm must
+ still gate on the children, not on the parent.
+ """
if not self.condition_new or not self.condition_damaged:
self.skipTest("Required vocabulary codes not found")
if not self.disposition_accept or not self.disposition_return:
self.skipTest("Required vocabulary codes not found")
donation = self._create_received_donation(quantity=1000)
- original_line = donation.line_ids[0]
-
wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
+
+ parent.action_add_split()
+ children = wizard.line_ids.filtered("is_split").sorted(key=lambda line: line.id)
+ self.assertEqual(len(children), 2)
+ child_a, child_b = children[0], children[1]
+ child_a.quantity = 700
+ child_b.quantity = 300
+
+ # Parent has no condition/action; children do — Confirm must succeed.
+ self._set_inspection(child_a, self.condition_new, self.disposition_accept)
+ self._set_inspection(child_b, self.condition_damaged, self.disposition_return)
+ self.assertFalse(parent.is_inspected)
+ wizard.action_confirm_inspection()
+ self.assertEqual(donation.state, "inspected")
- # Change first line: 800 good (already defaults to New/Accept)
- wizard.line_ids[0].write(
- {
- "quantity": 800,
- }
- )
+ def test_inspection_confirms_and_creates_splits(self):
+ """OP#963: full split flow via action_add_split produces both donation
+ lines downstream of confirm.
+ """
+ if not self.condition_new or not self.condition_damaged:
+ self.skipTest("Required vocabulary codes not found")
+ if not self.disposition_accept or not self.disposition_return:
+ self.skipTest("Required vocabulary codes not found")
- # Add second line: 200 damaged
- self.env["spp.drims.inspection.wizard.line"].create(
- {
- "wizard_id": wizard.id,
- "donation_line_id": original_line.id,
- "product_id": self.product.id,
- "uom_id": self.product.uom_id.id,
- "quantity_expected": 1000,
- "quantity": 200,
- "condition_id": self.condition_damaged.id,
- "disposition_id": self.disposition_return.id,
- "is_inspected": True,
- }
- )
+ donation = self._create_received_donation(quantity=1000)
+ wizard = self._open_inspection_wizard(donation)
+ parent = wizard.line_ids[0]
- # Validation should pass
- self.assertTrue(wizard.is_valid)
+ parent.action_add_split()
+ children = wizard.line_ids.filtered("is_split").sorted(key=lambda line: line.id)
+ self.assertEqual(len(children), 2)
+ child_a, child_b = children[0], children[1]
+ child_a.quantity = 800
+ child_b.quantity = 200
+ self._set_inspection(child_a, self.condition_new, self.disposition_accept)
+ self._set_inspection(child_b, self.condition_damaged, self.disposition_return)
- # Confirm
wizard.action_confirm_inspection()
-
- # Donation should be inspected
self.assertEqual(donation.state, "inspected")
- # Should have 2 lines now
self.assertEqual(len(donation.line_ids), 2)
lines = donation.line_ids.sorted("quantity_received", reverse=True)
self.assertEqual(lines[0].quantity_received, 800)
@@ -473,7 +604,7 @@ def test_inspection_confirms_and_creates_splits(self):
self.assertEqual(lines[1].condition_id, self.condition_damaged)
def test_inspection_notes_appended(self):
- """Test that inspection notes are appended to donation notes."""
+ """Inspection notes are appended to donation notes."""
if not self.condition_new or not self.disposition_accept:
self.skipTest("Required vocabulary codes not found")
@@ -481,7 +612,7 @@ def test_inspection_notes_appended(self):
donation.notes = "Original notes"
wizard = self._open_inspection_wizard(donation)
- # Lines already default to inspected/accepted
+ self._set_inspection(wizard.line_ids[0], self.condition_new, self.disposition_accept)
wizard.notes = "Inspection completed successfully"
wizard.action_confirm_inspection()
diff --git a/spp_drims/views/donation_views.xml b/spp_drims/views/donation_views.xml
index 3dbfffd12..b419fe085 100644
--- a/spp_drims/views/donation_views.xml
+++ b/spp_drims/views/donation_views.xml
@@ -49,8 +49,8 @@
invisible="state != 'announced'"
/>
+
-
+
+
+
+ Nothing to Stock.
+ Every line on this donation was marked for return, disposal, or quarantine
+ during inspection, so no items will enter inventory.
+ Click Reject to close this donation.
+
- Create a DRIMS Dispatch
+ No DRIMS Dispatches yet
- DRIMS dispatches are stock transfers that fulfill disaster relief requests.
- They include waybill tracking, transport details, and proof of delivery.
+ Dispatches are auto-generated when stock is allocated from an approved
+ request. They include waybill tracking, transport details, and proof
+ of delivery.
diff --git a/spp_drims/views/stock_warehouse_views.xml b/spp_drims/views/stock_warehouse_views.xml
index 9dbff1daa..7a27b9faa 100644
--- a/spp_drims/views/stock_warehouse_views.xml
+++ b/spp_drims/views/stock_warehouse_views.xml
@@ -73,46 +73,92 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
+ >
+
+
+
+
@@ -130,6 +176,8 @@
+
+
+
+
+
+
+
+
+
+
+
+ stock.warehouse.gis.drims
+ stock.warehouse
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Warehouse Location
+
+
+ basic
+
+ 0.9
+ #1F78B4
+
+
+
+ osm
+ Default
+
+
+
DRIMS Warehouses
diff --git a/spp_drims/wizard/__init__.py b/spp_drims/wizard/__init__.py
index b9257e5f8..a6ae1f0d2 100644
--- a/spp_drims/wizard/__init__.py
+++ b/spp_drims/wizard/__init__.py
@@ -1,5 +1,6 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
from . import bulk_approve_wizard
+from . import request_reject_wizard
from . import report_4w_wizard
from . import stock_adjustment_wizard
from . import inter_warehouse_transfer_wizard
diff --git a/spp_drims/wizard/allocation_preview_wizard.py b/spp_drims/wizard/allocation_preview_wizard.py
index a5c30422e..966cc4342 100644
--- a/spp_drims/wizard/allocation_preview_wizard.py
+++ b/spp_drims/wizard/allocation_preview_wizard.py
@@ -166,14 +166,35 @@ def _populate_lines(self):
self.line_ids = [Command.clear()] + lines
def _get_available_quantity(self, product, warehouse):
- """Get available quantity for a product in a warehouse."""
+ """Net available quantity of ``product`` in ``warehouse``.
+
+ Equals physical on-hand (minus stock.quant reservations) minus the
+ DRIMS allocations that have been committed but not yet dispatched.
+ Without this subtraction the wizard would happily over-allocate:
+ physical stock isn't touched at allocation time, only at dispatch,
+ so the same warehouse stock would appear "available" on every
+ wizard re-open even though prior allocations had already consumed
+ it logically (OP#1033 round 2).
+ """
quants = self.env["stock.quant"].search(
[
("product_id", "=", product.id),
("location_id", "child_of", warehouse.lot_stock_id.id),
]
)
- return sum(q.quantity - q.reserved_quantity for q in quants)
+ physical = sum(q.quantity - q.reserved_quantity for q in quants)
+
+ # Pending DRIMS allocations against this warehouse for this product
+ # — anything allocated but not yet dispatched is a logical reservation
+ # we should not promise again.
+ pending_lines = self.env["spp.drims.request.line"].search(
+ [
+ ("product_id", "=", product.id),
+ ("request_id.source_warehouse_id", "=", warehouse.id),
+ ]
+ )
+ pending = sum(max(0.0, line.quantity_allocated - line.quantity_dispatched) for line in pending_lines)
+ return max(0.0, physical - pending)
def action_confirm_allocation(self):
"""Apply the previewed allocation."""
@@ -182,6 +203,20 @@ def action_confirm_allocation(self):
if not self.line_ids:
raise UserError(_("No items to allocate."))
+ # OP#1032: refuse to confirm an empty allocation. Without this guard
+ # a user could pick a warehouse with zero available stock, the
+ # wizard would show 0 across all lines, and confirming would still
+ # advance the request to Ready for Dispatch with 0 allocated.
+ total_to_allocate = sum(self.line_ids.mapped("quantity_to_allocate"))
+ if total_to_allocate <= 0:
+ raise UserError(
+ _(
+ "No stock available in the selected warehouse. "
+ "Please ensure the source warehouse has sufficient items "
+ "before allocating."
+ )
+ )
+
_logger.info(
"Applying allocation for request %s from warehouse %s with %d lines",
self.request_id.reference,
diff --git a/spp_drims/wizard/inspection_wizard.py b/spp_drims/wizard/inspection_wizard.py
index ebb554d87..2b75671f1 100644
--- a/spp_drims/wizard/inspection_wizard.py
+++ b/spp_drims/wizard/inspection_wizard.py
@@ -2,11 +2,11 @@
"""
DRIMS Donation Inspection Wizard (DON-002)
-Batch Accept with Exceptions design:
-- Main wizard shows readonly list of items
-- "Accept All as New" button for 90% of cases (one-click)
-- "Edit" button per row opens popup to modify individual items
-- Supports splitting items with mixed conditions
+Inline-editable single-screen flow:
+- Operator sets Condition and Action directly on each row.
+- "+ Add split" creates a child row under the parent product for mixed conditions.
+- Parent qty mirrors the running sum of child rows; the parent itself carries no
+ condition / action — those live on the children.
"""
import logging
@@ -25,7 +25,7 @@
class InspectionWizard(models.TransientModel):
- """Main inspection wizard - shows items and allows batch accept or per-item edit."""
+ """Main inspection wizard - inline editing on every row."""
_name = "spp.drims.inspection.wizard"
_description = "Donation Inspection Wizard"
@@ -49,80 +49,18 @@ class InspectionWizard(models.TransientModel):
string="Inspection Notes",
help="General notes about the inspection",
)
- is_valid = fields.Boolean(
- string="Is Valid",
- compute="_compute_is_valid",
+ # The QtySplitProgressField widget pushes both of these from the
+ # editable list whenever any row's qty / condition / disposition
+ # changes. They drive the Confirm-button gate in the view.
+ # See ``_syncWizardCanConfirm`` in the JS widget.
+ can_confirm = fields.Boolean(
+ string="Can Confirm",
+ default=True,
+ )
+ confirm_message = fields.Text(
+ string="Confirm Blocker",
+ help="Human-readable reason(s) why Confirm Inspection is currently disabled.",
)
-
- @api.depends("line_ids.is_inspected", "line_ids.quantity", "line_ids.quantity_expected")
- def _compute_is_valid(self):
- """Check if all items are inspected and quantities match."""
- for wizard in self:
- if not wizard.line_ids:
- wizard.is_valid = False
- continue
-
- # Check all lines are inspected
- all_inspected = all(line.is_inspected for line in wizard.line_ids)
- if not all_inspected:
- wizard.is_valid = False
- continue
-
- # Check quantities match per product
- products = {}
- for line in wizard.line_ids:
- product_id = line.product_id.id
- if product_id not in products:
- products[product_id] = {
- "expected": line.quantity_expected,
- "total": 0.0,
- }
- products[product_id]["total"] += line.quantity
-
- quantities_match = all(abs(data["expected"] - data["total"]) < 0.001 for data in products.values())
- wizard.is_valid = quantities_match
-
- def action_accept_all(self):
- """Accept all items as New/Accept - one click for simple cases."""
- self.ensure_one()
-
- # Get default condition (new) and disposition (accept)
- condition_new = self.env["spp.vocabulary.code"].search(
- [
- ("vocabulary_id.namespace_uri", "=", VOCAB_ITEM_CONDITIONS),
- ("code", "=", "new"),
- ],
- limit=1,
- )
- disposition_accept = self.env["spp.vocabulary.code"].search(
- [
- ("vocabulary_id.namespace_uri", "=", VOCAB_ITEM_DISPOSITIONS),
- ("code", "=", "accept"),
- ],
- limit=1,
- )
-
- if not condition_new or not disposition_accept:
- raise UserError(_("Vocabulary codes for 'new' condition or 'accept' disposition not found."))
-
- # Mark all lines as inspected with default values
- for line in self.line_ids:
- line.write(
- {
- "condition_id": condition_new.id,
- "disposition_id": disposition_accept.id,
- "is_inspected": True,
- }
- )
-
- # Return to same wizard (refreshed)
- return {
- "type": "ir.actions.act_window",
- "res_model": self._name,
- "res_id": self.id,
- "view_mode": "form",
- "target": "new",
- }
def action_confirm_inspection(self):
"""Confirm inspection and create/update donation lines."""
@@ -131,41 +69,59 @@ def action_confirm_inspection(self):
if not self.line_ids:
raise UserError(_("No items to inspect."))
- # Validate all items are inspected
- uninspected = self.line_ids.filtered(lambda line: not line.is_inspected)
+ # Parent rows carry no condition/action; only validate the rows
+ # the operator actually filled in.
+ lines_to_check = self.line_ids.filtered(lambda line: not line.has_splits)
+ uninspected = lines_to_check.filtered(lambda line: not line.is_inspected)
if uninspected:
raise UserError(
_("Please inspect all items first. Uninspected: %s")
% ", ".join(uninspected.mapped("product_id.display_name"))
)
- # Validate quantities per product
- products = {}
+ # Group by the original donation line. Two separate donation lines for
+ # the same product are independent (each has its own donation_line_id),
+ # so they must not be lumped together as if they were splits of each
+ # other. Splits of a single donation line share the same
+ # ``donation_line_id`` via ``action_add_split``.
+ # Parent rows are skipped here — their qty mirrors their children's
+ # running total and including them would double-count.
+ lines_by_donation = {}
for line in self.line_ids:
- product_id = line.product_id.id
- if product_id not in products:
- products[product_id] = {
+ if line.has_splits:
+ continue
+ key = line.donation_line_id.id
+ if key not in lines_by_donation:
+ lines_by_donation[key] = {
"name": line.product_id.display_name,
"expected": line.quantity_expected,
"total": 0.0,
"lines": [],
}
- products[product_id]["total"] += line.quantity
- products[product_id]["lines"].append(line)
-
- for _product_id, data in products.items():
+ lines_by_donation[key]["total"] += line.quantity
+ lines_by_donation[key]["lines"].append(line)
+
+ # OP#964: only enforce equality when the user has split a donation
+ # line into multiple wizard lines. A single line is treated as the
+ # user reporting the final received quantity, and
+ # ``quantity_received`` on the donation line will be overwritten to
+ # match below.
+ for data in lines_by_donation.values():
+ if len(data["lines"]) <= 1:
+ continue
diff = abs(data["expected"] - data["total"])
if diff > 0.001:
raise UserError(
_(
- "Product %(product)s: Total inspected (%(total)s) must equal "
- "expected (%(expected)s). Difference: %(diff)s"
+ "Product %(product)s: split quantities total %(total)s but "
+ "received quantity is %(expected)s. Splits must sum to the "
+ "received quantity. Adjust splits or change the Received "
+ "quantity on the donation line first."
)
% {
"product": data["name"],
"total": data["total"],
"expected": data["expected"],
- "diff": diff,
}
)
@@ -175,12 +131,10 @@ def action_confirm_inspection(self):
len(self.line_ids),
)
- # Process each product group
DonationLine = self.env["spp.drims.donation.line"]
- for _product_id, data in products.items():
+ for data in lines_by_donation.values():
lines = data["lines"]
- # First line updates the original donation line
first_line = lines[0]
original_donation_line = first_line.donation_line_id
original_donation_line.write(
@@ -192,7 +146,6 @@ def action_confirm_inspection(self):
}
)
- # Additional lines create new donation lines (splits)
for line in lines[1:]:
if line.quantity <= 0:
continue
@@ -212,7 +165,6 @@ def action_confirm_inspection(self):
}
)
- # Transition donation to inspected state
inspected_state = self.env["spp.vocabulary.code"].search(
[
("vocabulary_id.namespace_uri", "=", VOCAB_DONATION_STATES),
@@ -232,10 +184,14 @@ def action_confirm_inspection(self):
class InspectionWizardLine(models.TransientModel):
- """Line in the inspection wizard - one per item/condition combination."""
+ """Line in the inspection wizard."""
_name = "spp.drims.inspection.wizard.line"
_description = "Inspection Wizard Line"
+ # Sort so each donation line's rows stay grouped together, with the
+ # parent row first (parent_line_id IS NULL) and its split children
+ # immediately below it (ordered by creation id).
+ _order = "donation_line_id, parent_line_id NULLS FIRST, id"
wizard_id = fields.Many2one(
"spp.drims.inspection.wizard",
@@ -248,6 +204,11 @@ class InspectionWizardLine(models.TransientModel):
string="Donation Line",
required=True,
)
+ parent_line_id = fields.Many2one(
+ "spp.drims.inspection.wizard.line",
+ string="Parent Line",
+ ondelete="cascade",
+ )
product_id = fields.Many2one(
"product.product",
string="Product",
@@ -281,141 +242,148 @@ class InspectionWizardLine(models.TransientModel):
)
is_inspected = fields.Boolean(
string="Inspected",
+ compute="_compute_is_inspected",
+ help="True once both Condition and Action are filled.",
+ )
+ is_split = fields.Boolean(
+ string="Is Split Line",
+ compute="_compute_is_split",
+ store=True,
+ )
+ # ``has_splits`` is a plain Boolean (no compute) because Odoo 19's
+ # editable-One2many reactivity does not reliably propagate cross-record
+ # depends like ``wizard_id.line_ids.parent_line_id``. action_add_split /
+ # action_remove_split write to this field explicitly, which keeps the
+ # readonly + visibility logic in the view reactive without round-tripping
+ # through a slow compute.
+ has_splits = fields.Boolean(
+ string="Has Split Lines",
+ default=False,
+ )
+ # Set by both ``action_add_split`` and the OWL widget's useEffect after
+ # the running split total catches up to (or exceeds) ``quantity_expected``.
+ # Drives the "+ Add split" button visibility on the parent row.
+ is_fully_split = fields.Boolean(
+ string="Is Fully Split",
default=False,
- help="Whether this item has been inspected",
)
- condition_display = fields.Char(
- string="Status",
- compute="_compute_condition_display",
+ can_mark_all_units = fields.Boolean(
+ string="Can Mark All Units",
+ compute="_compute_can_mark_all_units",
)
- @api.depends("is_inspected", "condition_id", "disposition_id")
- def _compute_condition_display(self):
- """Compute display string for condition/disposition."""
+ @api.depends("parent_line_id")
+ def _compute_is_split(self):
for line in self:
- if not line.is_inspected:
- line.condition_display = _("Not inspected")
- elif line.condition_id and line.disposition_id:
- line.condition_display = f"{line.condition_id.display} / {line.disposition_id.display}"
- else:
- line.condition_display = _("Incomplete")
-
- def action_edit_item(self):
- """Open popup to edit this item."""
+ line.is_split = bool(line.parent_line_id)
+
+ @api.depends("condition_id", "disposition_id")
+ def _compute_can_mark_all_units(self):
+ for line in self:
+ line.can_mark_all_units = bool(line.condition_id and line.disposition_id)
+
+ @api.depends("condition_id", "disposition_id")
+ def _compute_is_inspected(self):
+ """A row is inspected once both Condition and Action are filled."""
+ for line in self:
+ line.is_inspected = bool(line.condition_id and line.disposition_id)
+
+ @api.onchange("quantity")
+ def _onchange_quantity_update_parent(self):
+ """Keep the parent row qty in sync with the running sum of its children.
+
+ No clamping while drafting: the operator can freely adjust split
+ quantities (e.g. rebalance 500/500 to 400/600 without having to lower
+ one row first). An over- or under-split total is rejected at confirm
+ time by ``action_confirm_inspection`` (mirrored client-side by the
+ ``can_confirm`` guard), so eager clamping here only gets in the way.
+ """
+ if not self.parent_line_id:
+ return
+ siblings = self.wizard_id.line_ids.filtered(
+ lambda line: line.parent_line_id == self.parent_line_id and line != self
+ )
+ others_sum = sum(siblings.mapped("quantity"))
+ total = others_sum + (self.quantity or 0.0)
+ self.parent_line_id.quantity = total
+
+ def action_all_units(self):
+ """No-op handler for the visual "All units" badge in the list view.
+
+ The button is a visual indicator that Condition and Action are set —
+ clicking it just refreshes the wizard so any pending onchange values
+ are persisted. ``is_inspected`` is already flipped automatically via
+ ``_onchange_mark_inspected``.
+ """
self.ensure_one()
return {
"type": "ir.actions.act_window",
- "name": _("Inspect Item: %s") % self.product_id.display_name,
- "res_model": "spp.drims.inspection.item.wizard",
+ "res_model": "spp.drims.inspection.wizard",
+ "res_id": self.wizard_id.id,
"view_mode": "form",
"target": "new",
- "context": {
- "default_inspection_line_id": self.id,
- "default_wizard_id": self.wizard_id.id,
- "default_product_id": self.product_id.id,
- "default_uom_id": self.uom_id.id,
- "default_quantity_expected": self.quantity_expected,
- "default_quantity": self.quantity,
- "default_condition_id": self.condition_id.id if self.condition_id else False,
- "default_disposition_id": self.disposition_id.id if self.disposition_id else False,
- "default_notes": self.notes or "",
- },
}
-
-class InspectionItemWizard(models.TransientModel):
- """Popup wizard to edit a single inspection item."""
-
- _name = "spp.drims.inspection.item.wizard"
- _description = "Inspect Item Wizard"
-
- inspection_line_id = fields.Many2one(
- "spp.drims.inspection.wizard.line",
- string="Inspection Line",
- required=True,
- )
- wizard_id = fields.Many2one(
- "spp.drims.inspection.wizard",
- string="Main Wizard",
- required=True,
- )
- product_id = fields.Many2one(
- "product.product",
- string="Product",
- readonly=True,
- )
- uom_id = fields.Many2one(
- "uom.uom",
- string="Unit",
- readonly=True,
- )
- quantity_expected = fields.Float(
- string="Expected Quantity",
- readonly=True,
- )
- quantity = fields.Float(
- string="Quantity",
- required=True,
- help="Quantity in this condition. Reduce if splitting.",
- )
- condition_id = fields.Many2one(
- "spp.vocabulary.code",
- string="Condition",
- required=True,
- domain=f"[('vocabulary_id.namespace_uri', '=', '{VOCAB_ITEM_CONDITIONS}')]",
- )
- disposition_id = fields.Many2one(
- "spp.vocabulary.code",
- string="Disposition",
- required=True,
- domain=f"[('vocabulary_id.namespace_uri', '=', '{VOCAB_ITEM_DISPOSITIONS}')]",
- )
- notes = fields.Char(
- string="Notes",
- help="Notes about this item (e.g., 'water damaged', 'expired batch')",
- )
- remaining_qty = fields.Float(
- string="Remaining to Allocate",
- compute="_compute_remaining_qty",
- help="Quantity not yet allocated (for splitting)",
- )
- show_split_warning = fields.Boolean(
- compute="_compute_remaining_qty",
- )
-
- @api.depends("quantity", "quantity_expected")
- def _compute_remaining_qty(self):
- """Compute remaining quantity for this product."""
- InspectionLine = self.env["spp.drims.inspection.wizard.line"]
- for item in self:
- # Sum all lines for this product in the main wizard
- other_lines = InspectionLine.browse()
- for line in item.wizard_id.line_ids:
- if line.product_id == item.product_id and line.id != item.inspection_line_id.id:
- other_lines |= line
- other_total = sum(other_lines.mapped("quantity"))
- item.remaining_qty = item.quantity_expected - item.quantity - other_total
- item.show_split_warning = item.remaining_qty > 0.001
-
- def action_save(self):
- """Save changes and return to main wizard."""
+ def action_add_split(self):
+ """Create a new split child line under this row and reopen the wizard."""
self.ensure_one()
- if self.quantity < 0:
- raise UserError(_("Quantity cannot be negative."))
-
- # Update the inspection line
- self.inspection_line_id.write(
- {
- "quantity": self.quantity,
- "condition_id": self.condition_id.id,
- "disposition_id": self.disposition_id.id,
- "notes": self.notes,
- "is_inspected": True,
- }
+ # If the operator clicks "+ Add split" on a line that is itself a child,
+ # treat the parent as the root for the new split.
+ root_line = self.parent_line_id or self
+
+ # Sum only the existing children — never the parent. On the very first
+ # split the parent's qty still mirrors the full expected qty, so adding
+ # it would double-count and leave 0 remaining.
+ child_lines = self.wizard_id.line_ids.filtered(lambda line: line.parent_line_id == root_line)
+ total_in_splits = sum(child_lines.mapped("quantity"))
+ remaining = root_line.quantity_expected - total_in_splits
+
+ if remaining <= 0:
+ raise UserError(_("No remaining quantity to split. Reduce one of the existing split quantities first."))
+
+ # First split: reset the parent qty to 0 (it will be kept in sync as
+ # the running sum of children), mark the parent as split, and clear
+ # any Condition / Action the operator may have set before deciding
+ # to split — the parent row carries no decision; the children do.
+ # The first split divides the row into TWO empty parts (a split has
+ # to produce at least two pieces); each later "+ Add split" appends
+ # one more part.
+ if not child_lines:
+ root_line.quantity = 0
+ root_line.condition_id = False
+ root_line.disposition_id = False
+ root_line.has_splits = True
+ splits_to_create = 2
+ else:
+ splits_to_create = 1
+
+ # Create each new split with qty=0 so the operator has to enter the
+ # split amount explicitly. The "+ Add split" button is then hidden
+ # in the UI while any child still has qty=0, forcing the operator
+ # to fill the new rows before opening another. This avoids the
+ # "I added a split but forgot to set its quantity" footgun.
+ self.env["spp.drims.inspection.wizard.line"].create(
+ [
+ {
+ "wizard_id": self.wizard_id.id,
+ "donation_line_id": root_line.donation_line_id.id,
+ "product_id": root_line.product_id.id,
+ "uom_id": root_line.uom_id.id,
+ "quantity_expected": root_line.quantity_expected,
+ "quantity": 0,
+ "parent_line_id": root_line.id,
+ }
+ for _ in range(splits_to_create)
+ ]
)
- # Return to main wizard
+ # Running total is unchanged because the new children contribute 0;
+ # the parent still mirrors the sum of existing children.
+ root_line.quantity = total_in_splits
+ # The new children have qty 0, so the parent is not yet fully split.
+ root_line.is_fully_split = False
+
return {
"type": "ir.actions.act_window",
"res_model": "spp.drims.inspection.wizard",
@@ -424,39 +392,35 @@ def action_save(self):
"target": "new",
}
- def action_save_and_split(self):
- """Save changes and create a new line for remaining quantity."""
+ def action_remove_split(self):
+ """Remove a split child line and refresh the wizard."""
self.ensure_one()
-
- if self.quantity < 0:
- raise UserError(_("Quantity cannot be negative."))
-
- if self.remaining_qty <= 0:
- raise UserError(_("No remaining quantity to split. Adjust the quantity first."))
-
- # Update current line
- self.inspection_line_id.write(
- {
- "quantity": self.quantity,
- "condition_id": self.condition_id.id,
- "disposition_id": self.disposition_id.id,
- "notes": self.notes,
- "is_inspected": True,
- }
+ if not self.is_split:
+ raise UserError(_("Only split lines can be removed this way."))
+ wizard_id = self.wizard_id.id
+ root_line = self.parent_line_id
+
+ # Identify the children that will remain BEFORE unlinking. Reading the
+ # One2many after unlink can hit the still-cached unlinked record and
+ # raise MissingError when the lambda touches its fields.
+ remaining_children = root_line.wizard_id.line_ids.filtered(
+ lambda line: line.parent_line_id == root_line and line != self
)
- # Create new line for remaining quantity
- new_line = self.env["spp.drims.inspection.wizard.line"].create(
- {
- "wizard_id": self.wizard_id.id,
- "donation_line_id": self.inspection_line_id.donation_line_id.id,
- "product_id": self.product_id.id,
- "uom_id": self.uom_id.id,
- "quantity_expected": self.quantity_expected,
- "quantity": self.remaining_qty,
- "is_inspected": False, # New split needs to be inspected
- }
- )
+ self.unlink()
- # Open edit popup for the new split
- return new_line.action_edit_item()
+ if not remaining_children:
+ # Reset parent back to the full expected quantity and clear the
+ # split flag so the row reverts to a normal (editable) row.
+ root_line.quantity = root_line.quantity_expected
+ root_line.has_splits = False
+ else:
+ root_line.quantity = sum(remaining_children.mapped("quantity"))
+
+ return {
+ "type": "ir.actions.act_window",
+ "res_model": "spp.drims.inspection.wizard",
+ "res_id": wizard_id,
+ "view_mode": "form",
+ "target": "new",
+ }
diff --git a/spp_drims/wizard/inspection_wizard_views.xml b/spp_drims/wizard/inspection_wizard_views.xml
index 553f18dba..dfea4c494 100644
--- a/spp_drims/wizard/inspection_wizard_views.xml
+++ b/spp_drims/wizard/inspection_wizard_views.xml
@@ -16,53 +16,145 @@
-
+
+
-
-
- All items are set to New / Accept.
- Click Confirm Inspection to proceed, or Edit any row to change.
+
+ Set Condition and Action on each row, then click Confirm Inspection. Use + Add split when a single product needs to be split
+ across multiple conditions.
-
- Some items need attention. Please review and edit as needed.
+
+