diff --git a/spp_drims/README.rst b/spp_drims/README.rst
index 35393c233..59cd6565a 100644
--- a/spp_drims/README.rst
+++ b/spp_drims/README.rst
@@ -179,6 +179,25 @@ Dependencies
Changelog
=========
+19.0.4.0.0
+~~~~~~~~~~
+
+- feat(drims): Donations review — creation, receipt, inspection and
+ follow-up. Donations start in a new **Draft** state; the donor list is
+ limited to organisations whose role is Donor and a donation cannot be
+ recorded against a closed incident; at least one item is required to
+ save, Pledged must be entered and be greater than zero, and Received
+ is entered manually rather than copied from Pledged. Line columns
+ appear progressively through the lifecycle (Received and Variance from
+ Announced; Condition and Action from Inspected), non-accepted items
+ gain a follow-up/disposal trail, and adding an item is blocked once
+ the donation has moved past its editable states (#1055, #1058, #1108,
+ #1163)
+- **Breaking:** the donation line's **Description** field is removed. It
+ was replaced by the product and quantity columns during this rework;
+ the database column is left in place, so existing values are retained
+ but no longer readable through the ORM or shown in any view (#1076)
+
19.0.3.0.0
~~~~~~~~~~
diff --git a/spp_drims/__manifest__.py b/spp_drims/__manifest__.py
index e775182ab..d4c083158 100644
--- a/spp_drims/__manifest__.py
+++ b/spp_drims/__manifest__.py
@@ -5,7 +5,7 @@
"and distribution tracking. Links to hazard incidents with multi-tier "
"approval workflows and warehouse operations.",
"category": "OpenSPP/Inventory",
- "version": "19.0.3.0.0",
+ "version": "19.0.4.0.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -60,6 +60,7 @@
"wizard/allocation_preview_wizard_views.xml",
"wizard/create_return_wizard_views.xml",
"wizard/inspection_wizard_views.xml",
+ "wizard/receive_wizard_views.xml",
# Views
"views/alert_views.xml",
"views/donation_views.xml",
@@ -89,6 +90,7 @@
"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",
+ "spp_drims/static/src/css/donation_form.css",
"spp_drims/static/src/css/request_form.css",
],
},
diff --git a/spp_drims/data/vocabulary_codes.xml b/spp_drims/data/vocabulary_codes.xml
index c8ea0a22e..21e3d491d 100644
--- a/spp_drims/data/vocabulary_codes.xml
+++ b/spp_drims/data/vocabulary_codes.xml
@@ -210,6 +210,12 @@
+
+
+ draft
+ Draft
+ 5
+ announced
diff --git a/spp_drims/models/constants.py b/spp_drims/models/constants.py
index c9033e598..f0c3af8af 100644
--- a/spp_drims/models/constants.py
+++ b/spp_drims/models/constants.py
@@ -31,7 +31,13 @@
STATE_DISPATCHED = "dispatched"
STATE_DELIVERED = "delivered"
+# Donation-line disposition codes that should NOT be stocked. Moves for these
+# get cancelled at stocking, and the items then need a follow-up disposal
+# (return / dispose / quarantine) tracked on the line — see OP#1058.
+NON_ACCEPT_DISPOSITIONS = ("return", "dispose", "quarantine")
+
# Donation state codes
+DONATION_STATE_DRAFT = "draft"
DONATION_STATE_ANNOUNCED = "announced"
DONATION_STATE_RECEIVED = "received"
DONATION_STATE_INSPECTED = "inspected"
diff --git a/spp_drims/models/donation.py b/spp_drims/models/donation.py
index 5938145f3..2cf60efdd 100644
--- a/spp_drims/models/donation.py
+++ b/spp_drims/models/donation.py
@@ -7,11 +7,13 @@
from .constants import (
DONATION_STATE_ANNOUNCED,
DONATION_STATE_CANCELLED,
+ DONATION_STATE_DRAFT,
DONATION_STATE_INSPECTED,
DONATION_STATE_RECEIVED,
DONATION_STATE_REJECTED,
DONATION_STATE_STOCKED,
DRIMS_TYPE_DONATION_RECEIPT,
+ NON_ACCEPT_DISPOSITIONS,
VOCAB_DONATION_STATES,
VOCAB_DONOR_TYPES,
VOCAB_DRIMS_TYPES,
@@ -20,14 +22,10 @@
_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_DRAFT: [DONATION_STATE_ANNOUNCED, DONATION_STATE_CANCELLED],
DONATION_STATE_ANNOUNCED: [DONATION_STATE_RECEIVED, DONATION_STATE_CANCELLED],
DONATION_STATE_RECEIVED: [DONATION_STATE_INSPECTED, DONATION_STATE_CANCELLED],
DONATION_STATE_INSPECTED: [
@@ -68,11 +66,15 @@ class DrimsDonation(models.Model):
required=True,
tracking=True,
index=True,
+ # OP#1076: a donation cannot be attached to a closed incident.
+ domain="[('status', '!=', 'closed')]",
)
donor_id = fields.Many2one(
"res.partner",
string="Donor",
tracking=True,
+ # OP#1076: only DRIMS organisations whose role is "Donor".
+ domain="[('is_drims_organization', '=', True), ('drims_organization_role_id.code', '=', 'donor')]",
)
donor_name = fields.Char(
string="Donor Name",
@@ -145,6 +147,28 @@ class DrimsDonation(models.Model):
has_acceptable_items = fields.Boolean(
compute="_compute_has_acceptable_items",
)
+ # OP#1076: the line-table "Expiry Date" column is only shown when the
+ # optional product_expiry module is installed.
+ product_expiry_installed = fields.Boolean(
+ compute="_compute_product_expiry_installed",
+ )
+ # OP#1076: lines whose inspection disposition excludes them from stock
+ # (return/dispose/quarantine) — surfaced in a separate "Items Not Accepted
+ # for Stock" table once the donation has been inspected.
+ non_accepted_line_ids = fields.One2many(
+ "spp.drims.donation.line",
+ compute="_compute_non_accepted_line_ids",
+ string="Items Not Accepted for Stock",
+ )
+ # OP#1058: the accepted lines only (disposition is not return/dispose/
+ # quarantine). Shown as the "Donation Items" table once the donation has
+ # been inspected, so non-accepted items are not duplicated across both the
+ # Donation Items and "Items Not Accepted for Stock" tables.
+ accepted_line_ids = fields.One2many(
+ "spp.drims.donation.line",
+ compute="_compute_accepted_line_ids",
+ string="Accepted Donation Items",
+ )
# Stock
picking_ids = fields.One2many(
@@ -210,12 +234,32 @@ def write(self, vals):
self._invalidate_incident_kpi_cache(self)
return result
+ @api.constrains("incident_id")
+ def _check_incident_not_closed(self):
+ """OP#1076: a donation cannot be recorded against a closed incident."""
+ for rec in self:
+ if rec.incident_id.status == "closed":
+ raise ValidationError(
+ _("Incident '%s' is closed — donations cannot be recorded against it.")
+ % rec.incident_id.display_name
+ )
+
+ @api.constrains("line_ids", "state")
+ def _check_has_lines(self):
+ """OP#1076: at least one donation item is required (from draft onward).
+
+ Cancelled donations are exempt so an empty draft can still be cancelled.
+ """
+ for rec in self:
+ if rec.state != DONATION_STATE_CANCELLED and not rec.line_ids:
+ raise ValidationError(_("Add at least one item before saving the donation."))
+
@api.model
def _get_default_state(self):
return self.env["spp.vocabulary.code"].search(
[
("vocabulary_id.namespace_uri", "=", VOCAB_DONATION_STATES),
- ("code", "=", DONATION_STATE_ANNOUNCED),
+ ("code", "=", DONATION_STATE_DRAFT),
],
limit=1,
)
@@ -245,6 +289,28 @@ def _compute_has_acceptable_items(self):
for line in rec.line_ids
)
+ def _compute_product_expiry_installed(self):
+ # product_expiry adds ``expiration_date`` to stock.lot; checking the
+ # field registry avoids querying ir.module.module (and the sudo that
+ # would require), and matches how action_stock detects it.
+ installed = "expiration_date" in self.env["stock.lot"]._fields
+ for rec in self:
+ rec.product_expiry_installed = installed
+
+ @api.depends("line_ids.disposition_id")
+ def _compute_non_accepted_line_ids(self):
+ for rec in self:
+ rec.non_accepted_line_ids = rec.line_ids.filtered(
+ lambda line: (line.disposition_id.code or "") in NON_ACCEPT_DISPOSITIONS
+ )
+
+ @api.depends("line_ids.disposition_id")
+ def _compute_accepted_line_ids(self):
+ for rec in self:
+ rec.accepted_line_ids = rec.line_ids.filtered(
+ lambda line: (line.disposition_id.code or "") not in NON_ACCEPT_DISPOSITIONS
+ )
+
@api.depends("picking_ids")
def _compute_picking_count(self):
for rec in self:
@@ -260,17 +326,78 @@ def create(self, vals_list):
self._invalidate_incident_kpi_cache(records)
return records
+ def action_mark_announced(self):
+ """Mark a draft donation as announced (OP#1076).
+
+ Moves the donation from 'draft' to 'announced'. Only from this point
+ is the "Mark Received" action available and are the Received/Variance
+ columns shown for manual entry.
+ """
+ announced_state = self.env["spp.vocabulary.code"].search(
+ [
+ ("vocabulary_id.namespace_uri", "=", VOCAB_DONATION_STATES),
+ ("code", "=", DONATION_STATE_ANNOUNCED),
+ ],
+ limit=1,
+ )
+ for rec in self:
+ if rec.state != DONATION_STATE_DRAFT:
+ raise UserError(_("Only draft donations can be marked as announced."))
+ if not rec.line_ids:
+ raise UserError(_("Add at least one item before announcing the donation."))
+ rec.state_id = announced_state
+
+ def action_open_receive_wizard(self):
+ """OP#1163: open the Mark Received wizard to enter received quantities.
+
+ Mirrors the Inspect Items flow: pre-creates the wizard + one line per
+ donation item (Received pre-filled from the pledged quantity) so the
+ operator confirms/edits the quantities on a single screen instead of
+ hitting an error when they weren't entered yet.
+ """
+ self.ensure_one()
+ if self.state != DONATION_STATE_ANNOUNCED:
+ raise UserError(_("Only announced donations can be marked as received."))
+ wizard = self.env["spp.drims.receive.wizard"].create({"donation_id": self.id})
+ line_vals = [
+ {
+ "wizard_id": wizard.id,
+ "donation_line_id": line.id,
+ "product_id": line.product_id.id,
+ "uom_id": line.uom_id.id,
+ "quantity_pledged": line.quantity_pledged,
+ "quantity_received": line.quantity_received or line.quantity_pledged,
+ }
+ for line in self.line_ids
+ ]
+ if line_vals:
+ self.env["spp.drims.receive.wizard.line"].create(line_vals)
+ return {
+ "type": "ir.actions.act_window",
+ "name": _("Mark Received"),
+ "res_model": "spp.drims.receive.wizard",
+ "res_id": wizard.id,
+ "view_mode": "form",
+ "target": "new",
+ }
+
def action_mark_received(self):
"""Mark donation as received and create stock picking.
This action:
1. Updates the donation state to 'received'
2. Sets the date_received to today
- 3. Sets quantity_received = quantity_pledged for all lines without received qty
- 4. Creates a stock.picking (incoming) for receiving items into warehouse
+ 3. Creates a stock.picking (incoming) for receiving items into warehouse
+
+ OP#1076: received quantities are entered MANUALLY on the announced
+ donation (the Received column), not auto-copied from the pledged
+ quantity. At least one line must have a received quantity > 0 before
+ the donation can be marked received.
Raises:
- UserError: If no incoming picking type found for the warehouse.
+ UserError: If the donation is not announced, if no received
+ quantity has been entered, or if no incoming picking type is
+ found for the warehouse.
"""
received_state = self.env["spp.vocabulary.code"].search(
[
@@ -287,12 +414,14 @@ def action_mark_received(self):
limit=1,
)
for rec in self:
+ if rec.state != DONATION_STATE_ANNOUNCED:
+ raise UserError(_("Only announced donations can be marked as received."))
+ if not any(line.quantity_received > 0 for line in rec.line_ids):
+ raise UserError(
+ _("Enter the received quantity on at least one item before marking the donation received.")
+ )
rec.state_id = received_state
rec.date_received = fields.Date.context_today(self)
- # Mark all lines as received with pledged quantity
- for line in rec.line_ids:
- if line.quantity_received == 0:
- line.quantity_received = line.quantity_pledged
# Create stock picking for receipt
rec._create_receipt_picking(drims_type)
@@ -478,6 +607,14 @@ def action_stock(self):
if rec.state != DONATION_STATE_INSPECTED:
raise UserError(_("Only inspected donations can be marked as stocked."))
rec.state_id = stocked_state
+ # OP#1058: items excluded from stock (non-accept disposition) now
+ # need a follow-up disposal — mark them Pending so they surface in
+ # the "Non-Accepted Items" tracking list.
+ rec.line_ids.filtered(
+ lambda line: (line.disposition_id.code or "") in NON_ACCEPT_DISPOSITIONS
+ and line.quantity_received > 0
+ and not line.disposal_state
+ ).write({"disposal_state": "pending"})
# 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))
diff --git a/spp_drims/models/donation_line.py b/spp_drims/models/donation_line.py
index f23b9fe7a..338d137c8 100644
--- a/spp_drims/models/donation_line.py
+++ b/spp_drims/models/donation_line.py
@@ -1,5 +1,8 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+from .constants import NON_ACCEPT_DISPOSITIONS
class DrimsDonationLine(models.Model):
@@ -22,16 +25,13 @@ class DrimsDonationLine(models.Model):
string="Product",
required=True,
)
- description = fields.Char(
- string="Description",
- help="Additional description if product not found",
- )
# Quantities
+ # OP#1076: no default — the pledged quantity must be entered explicitly
+ # (enforced by _check_quantity_pledged) so it is never silently saved as 0.
quantity_pledged = fields.Float(
string="Quantity Pledged",
required=True,
- default=1.0,
help="Original quantity announced by donor",
)
quantity_received = fields.Float(
@@ -81,6 +81,33 @@ class DrimsDonationLine(models.Model):
notes = fields.Text(string="Notes")
+ # OP#1058: follow-up tracking for items excluded from stock (a non-accept
+ # disposition). Seeded to "pending" when the donation is stocked, then
+ # resolved once the return / disposal / quarantine has been actioned.
+ is_non_accept = fields.Boolean(
+ string="Non-Accept",
+ compute="_compute_is_non_accept",
+ store=True,
+ help="The disposition (Return / Dispose / Quarantine) excludes this item from stock.",
+ )
+ disposal_state = fields.Selection(
+ [("pending", "Pending"), ("resolved", "Resolved")],
+ string="Disposal Status",
+ copy=False,
+ help="Follow-up status for an item excluded from stock.",
+ )
+ disposal_date = fields.Date(string="Resolved On", copy=False)
+ disposal_user_id = fields.Many2one("res.users", string="Resolved By", copy=False)
+ disposal_notes = fields.Text(
+ string="Disposal Notes",
+ help="What was done with the excluded item (returned to donor, disposed, quarantine outcome, ...).",
+ )
+
+ @api.depends("disposition_id")
+ def _compute_is_non_accept(self):
+ for line in self:
+ line.is_non_accept = (line.disposition_id.code or "") in NON_ACCEPT_DISPOSITIONS
+
# Variance (GAP-DON-003)
receipt_variance = fields.Float(
string="Variance",
@@ -112,18 +139,62 @@ def _compute_value(self):
for line in self:
line.value = line.quantity * line.unit_value
+ @api.constrains("quantity_pledged")
+ def _check_quantity_pledged(self):
+ """OP#1076: the pledged quantity must be a positive number."""
+ for line in self:
+ if line.quantity_pledged <= 0:
+ raise ValidationError(_("Pledged quantity must be greater than zero."))
+
+ @api.constrains("quantity_received")
+ def _check_quantity_received(self):
+ """A received quantity may be zero, but never negative (OP#1076 review).
+
+ Zero is meaningful — an item that was pledged and never arrived — and
+ `action_mark_received` already requires at least one line above zero. A
+ negative slips past that check whenever another line is positive, and
+ then reaches the receipt picking, where it fails as an obscure stock
+ error a long way from the field that caused it.
+ """
+ for line in self:
+ if line.quantity_received < 0:
+ raise ValidationError(_("Received quantity cannot be negative."))
+
+ def action_mark_disposal_resolved(self):
+ """OP#1058: record that an excluded item has been handled.
+
+ Sets the line to resolved with who/when, and posts an audit note on
+ the donation so there is an accountability trail.
+ """
+ today = fields.Date.context_today(self)
+ for line in self:
+ if line.disposal_state != "pending":
+ continue
+ line.write(
+ {
+ "disposal_state": "resolved",
+ "disposal_date": today,
+ "disposal_user_id": self.env.user.id,
+ }
+ )
+ line.donation_id.message_post(
+ body=_("Non-accepted item resolved: %(product)s — %(qty)s %(uom)s, %(action)s.%(notes)s")
+ % {
+ "product": line.product_id.display_name,
+ "qty": line.quantity_received or line.quantity,
+ "uom": line.uom_id.name or "",
+ "action": line.disposition_id.display_name or _("non-accept"),
+ "notes": (f" {line.disposal_notes}") if line.disposal_notes else "",
+ }
+ )
+ return True
+
@api.onchange("product_id")
def _onchange_product_id(self):
if self.product_id:
self.uom_id = self.product_id.uom_id
self.unit_value = self.product_id.standard_price
- def action_mark_received(self):
- """Mark line as received with pledged quantity."""
- for line in self:
- if line.quantity_received == 0:
- line.quantity_received = line.quantity_pledged
-
@api.model_create_multi
def create(self, vals_list):
# Set uom_id from product if not provided
@@ -132,10 +203,27 @@ def create(self, vals_list):
product = self.env["product.product"].browse(vals["product_id"])
vals["uom_id"] = product.uom_id.id
records = super().create(vals_list)
+ # OP#1055: items can be added while the donation is still in draft or
+ # announced (QA needs to add a line during receiving); the list is
+ # locked from the received state onward. The inspection wizard creates
+ # split rows on a received donation and opts out via the
+ # allow_donation_line_create context.
+ if not self.env.context.get("allow_donation_line_create"):
+ for line in records:
+ if line.donation_id.state and line.donation_id.state not in ("draft", "announced"):
+ raise ValidationError(_("Items can only be added while the donation is in draft or announced."))
# Invalidate KPI cache for affected incidents
self._invalidate_incident_kpi_cache(records)
return records
+ def unlink(self):
+ # OP#1055: items can be removed while the donation is still in draft or
+ # announced; the list is locked from the received state onward.
+ for line in self:
+ if line.donation_id.state and line.donation_id.state not in ("draft", "announced"):
+ raise ValidationError(_("Items can only be removed while the donation is in draft or announced."))
+ return super().unlink()
+
def write(self, vals):
result = super().write(vals)
# Invalidate KPI cache for affected incidents
diff --git a/spp_drims/readme/HISTORY.md b/spp_drims/readme/HISTORY.md
index 41a4b4ea2..4751fa7ae 100644
--- a/spp_drims/readme/HISTORY.md
+++ b/spp_drims/readme/HISTORY.md
@@ -1,3 +1,8 @@
+### 19.0.4.0.0
+
+- feat(drims): Donations review — creation, receipt, inspection and follow-up. Donations start in a new **Draft** state; the donor list is limited to organisations whose role is Donor and a donation cannot be recorded against a closed incident; at least one item is required to save, Pledged must be entered and be greater than zero, and Received is entered manually rather than copied from Pledged. Line columns appear progressively through the lifecycle (Received and Variance from Announced; Condition and Action from Inspected), non-accepted items gain a follow-up/disposal trail, and adding an item is blocked once the donation has moved past its editable states (#1055, #1058, #1108, #1163)
+- **Breaking:** the donation line's **Description** field is removed. It was replaced by the product and quantity columns during this rework; the database column is left in place, so existing values are retained but no longer readable through the ORM or shown in any view (#1076)
+
### 19.0.3.0.0
- feat(drims): allocate stock per source warehouse. The Allocate Stock wizard now auto-splits each requested line across the DRIMS warehouses that hold stock (e.g. 70 → 50 @ WH1 + 20 @ WH2) with editable rows; the split is captured on a new per-warehouse allocation record, shown on the request's Allocations tab and summarised in a "Source Warehouse(s)" column on the Requests list; dispatch creates one picking per source warehouse. The single "Source Warehouse" field on the request has been removed — the warehouse(s) are chosen in the wizard. The allocation wizard distinguishes no-stock, stock-shortfall and deliberate partial-allocation cases with clear messages, and the request line's Fulfillment % tracks allocated ÷ requested so the bar reflects allocation progress (#1079)
diff --git a/spp_drims/security/ir.model.access.csv b/spp_drims/security/ir.model.access.csv
index 632f35dc6..e0f61e2e3 100644
--- a/spp_drims/security/ir.model.access.csv
+++ b/spp_drims/security/ir.model.access.csv
@@ -163,6 +163,12 @@ 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_receive_wizard_manager,DRIMS Receive Wizard Manager,model_spp_drims_receive_wizard,group_drims_manager,1,1,1,1
+access_spp_drims_receive_wizard_line_manager,DRIMS Receive Wizard Line Manager,model_spp_drims_receive_wizard_line,group_drims_manager,1,1,1,1
+access_spp_drims_receive_wizard_officer,DRIMS Receive Wizard Officer,model_spp_drims_receive_wizard,group_drims_officer,1,1,1,0
+access_spp_drims_receive_wizard_line_officer,DRIMS Receive Wizard Line Officer,model_spp_drims_receive_wizard_line,group_drims_officer,1,1,1,0
+access_spp_drims_receive_wizard_warehouse_staff,DRIMS Receive Wizard Warehouse Staff,model_spp_drims_receive_wizard,group_drims_warehouse_worker,1,1,1,0
+access_spp_drims_receive_wizard_line_warehouse_staff,DRIMS Receive Wizard Line Warehouse Staff,model_spp_drims_receive_wizard_line,group_drims_warehouse_worker,1,1,1,0
access_spp_drims_request_allocation_sysadmin,DRIMS Request Allocation System Admin,model_spp_drims_request_allocation,base.group_system,1,1,1,1
access_spp_drims_request_allocation_admin,DRIMS Request Allocation Admin,model_spp_drims_request_allocation,spp_security.group_spp_admin,1,1,1,1
access_spp_drims_request_allocation_read,DRIMS Request Allocation Read,model_spp_drims_request_allocation,group_drims_read,1,0,0,0
diff --git a/spp_drims/static/description/index.html b/spp_drims/static/description/index.html
index f64fb3c62..38ffff4e9 100644
--- a/spp_drims/static/description/index.html
+++ b/spp_drims/static/description/index.html
@@ -565,6 +565,26 @@
feat(drims): Donations review — creation, receipt, inspection and
+follow-up. Donations start in a new Draft state; the donor list is
+limited to organisations whose role is Donor and a donation cannot be
+recorded against a closed incident; at least one item is required to
+save, Pledged must be entered and be greater than zero, and Received
+is entered manually rather than copied from Pledged. Line columns
+appear progressively through the lifecycle (Received and Variance from
+Announced; Condition and Action from Inspected), non-accepted items
+gain a follow-up/disposal trail, and adding an item is blocked once
+the donation has moved past its editable states (#1055, #1058, #1108,
+#1163)
+
Breaking: the donation line’s Description field is removed. It
+was replaced by the product and quantity columns during this rework;
+the database column is left in place, so existing values are retained
+but no longer readable through the ORM or shown in any view (#1076)
+
+
+
19.0.3.0.0
feat(drims): allocate stock per source warehouse. The Allocate Stock
@@ -584,7 +604,7 @@
19.0.3.0.0
destination-type selector (#1075)
-
+
19.0.2.0.0
Initial migration to OpenSPP2
diff --git a/spp_drims/static/src/css/donation_form.css b/spp_drims/static/src/css/donation_form.css
new file mode 100644
index 000000000..8d60792ad
--- /dev/null
+++ b/spp_drims/static/src/css/donation_form.css
@@ -0,0 +1,24 @@
+/*
+ * OP#1058 (QA follow-up): the donation form's "Donation Items" and "Items Not
+ * Accepted for Stock" tables are short x2many lists. Odoo pads every embedded
+ * x2many list out to four rows with blank filler rows
+ * (ListRenderer.getEmptyRowIds), which read as stray "extra lines" under the
+ * real data. Hide those fillers on these two tables only.
+ *
+ * OP#1076 round 2: target the fillers by having no class at all, which is what
+ * distinguishes them. The previous selector excluded
+ * `.o_field_x2many_list_row_add`, on the assumption that class sits on the row
+ * — in Odoo 19 it is on the
, and the row itself is
+ * `
`. So the exclusion never matched and the rule hid
+ * the "Add a line" row, which is why a new donation offered no way to add
+ * items at all.
+ *
+ * Filler rows carry no class; data rows carry `o_data_row`; the add row carries
+ * `d-print-none`. Matching on the absence of a class therefore hits exactly the
+ * fillers, and if Odoo ever gives them one the rule simply stops applying and
+ * the blank rows come back — a cosmetic regression rather than a form nobody
+ * can enter data into. Totals live in
and are unaffected.
+ */
+.o_drims_donation_list .o_list_table > tbody > tr:not([class]) {
+ display: none;
+}
diff --git a/spp_drims/tests/common.py b/spp_drims/tests/common.py
index 38ca21e8c..2e3343dfa 100644
--- a/spp_drims/tests/common.py
+++ b/spp_drims/tests/common.py
@@ -44,6 +44,17 @@ def setUpClass(cls):
],
limit=1,
)
+ cls.state_donation_draft = cls.vocab_code.search(
+ [
+ (
+ "vocabulary_id.namespace_uri",
+ "=",
+ "urn:openspp:vocab:drims:donation-states",
+ ),
+ ("code", "=", "draft"),
+ ],
+ limit=1,
+ )
cls.state_announced = cls.vocab_code.search(
[
(
@@ -112,3 +123,20 @@ def setUpClass(cls):
"standard_price": 100.0,
}
)
+
+ def _receive_donation(self, donation, received=None):
+ """Advance a donation to the 'received' state via the OP#1076 flow.
+
+ The lifecycle is draft -> announced -> received. Received quantities
+ are entered manually post-OP#1076 (no auto-copy from pledged), so this
+ helper fills any line that has no received quantity with its pledged
+ quantity — preserving the pre-OP#1076 received==pledged expectation —
+ then marks the donation received (which creates the receipt picking).
+ """
+ if donation.state == "draft":
+ donation.action_mark_announced()
+ for line in donation.line_ids:
+ if line.quantity_received <= 0:
+ line.quantity_received = received if received is not None else line.quantity_pledged
+ donation.action_mark_received()
+ return donation
diff --git a/spp_drims/tests/test_activity_feed.py b/spp_drims/tests/test_activity_feed.py
index 84ff97b61..3dc0d1e8d 100644
--- a/spp_drims/tests/test_activity_feed.py
+++ b/spp_drims/tests/test_activity_feed.py
@@ -27,6 +27,9 @@ def test_donation_creates_audit_log(self):
"incident_id": self.incident.id,
"warehouse_id": self.warehouse.id,
"donor_name": "Test Donor for Audit",
+ "line_ids": [
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 10, "uom_id": self.product.uom_id.id})
+ ],
}
)
diff --git a/spp_drims/tests/test_donation.py b/spp_drims/tests/test_donation.py
index 1dd4b7a56..95056b36d 100644
--- a/spp_drims/tests/test_donation.py
+++ b/spp_drims/tests/test_donation.py
@@ -1,5 +1,9 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+import re
from datetime import date, timedelta
+from pathlib import Path
+
+from lxml import etree
from odoo.exceptions import UserError, ValidationError
from odoo.tests import tagged
@@ -18,10 +22,21 @@ def test_create_donation(self):
"incident_id": self.incident.id,
"warehouse_id": self.warehouse.id,
"donor_name": "Test Donor",
+ "line_ids": [
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 10,
+ "uom_id": self.product.uom_id.id,
+ },
+ )
+ ],
}
)
self.assertTrue(donation.reference.startswith("DON-"))
- self.assertEqual(donation.state, "announced")
+ self.assertEqual(donation.state, "draft")
def test_donation_with_lines(self):
"""Test donation with line items."""
@@ -94,7 +109,7 @@ def test_mark_received(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
self.assertEqual(donation.state, "received")
self.assertTrue(donation.date_received)
# Check line received quantity is set
@@ -122,7 +137,7 @@ def test_mark_received_creates_picking(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
picking = donation.picking_ids[0]
self.assertEqual(picking.drims_donation_id, donation)
self.assertEqual(picking.incident_id, self.incident)
@@ -154,7 +169,7 @@ def test_donation_workflow_inspect(self):
with self.assertRaises(UserError):
donation.action_inspect()
# After receiving
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
self.assertEqual(donation.state, "inspected")
@@ -194,6 +209,17 @@ def test_donation_with_donor_partner(self):
"incident_id": self.incident.id,
"warehouse_id": self.warehouse.id,
"donor_id": partner.id,
+ "line_ids": [
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 10,
+ "uom_id": self.product.uom_id.id,
+ },
+ )
+ ],
}
)
self.assertEqual(donation.donor_id, partner)
@@ -219,6 +245,17 @@ def test_donation_donor_type(self):
"warehouse_id": self.warehouse.id,
"donor_name": "NGO Donor",
"source_type_id": donor_type.id,
+ "line_ids": [
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product.id,
+ "quantity_pledged": 10,
+ "uom_id": self.product.uom_id.id,
+ },
+ )
+ ],
}
)
self.assertEqual(donation.source_type_id, donor_type)
@@ -254,6 +291,9 @@ def test_donation_unique_reference(self):
"incident_id": self.incident.id,
"warehouse_id": self.warehouse.id,
"donor_name": "Test Donor 1",
+ "line_ids": [
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 10, "uom_id": self.product.uom_id.id})
+ ],
}
)
# Verify reference was generated
@@ -265,6 +305,9 @@ def test_donation_unique_reference(self):
"incident_id": self.incident.id,
"warehouse_id": self.warehouse.id,
"donor_name": "Test Donor 2",
+ "line_ids": [
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 10, "uom_id": self.product.uom_id.id})
+ ],
}
)
self.assertNotEqual(donation1.reference, donation2.reference)
@@ -313,7 +356,7 @@ def test_donation_view_pickings_action(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
action = donation.action_view_pickings()
self.assertEqual(action["res_model"], "stock.picking")
self.assertEqual(action["domain"], [("drims_donation_id", "=", donation.id)])
@@ -340,7 +383,7 @@ def test_donation_partial_receipt(self):
)
# Manually set received quantity before marking received
donation.line_ids[0].quantity_received = 75
- donation.action_mark_received()
+ self._receive_donation(donation)
# Should keep the manually set quantity
self.assertEqual(donation.line_ids[0].quantity_received, 75)
@@ -406,7 +449,7 @@ def test_invalid_state_transition_constraint(self):
],
}
)
- self.assertEqual(donation.state, "announced")
+ self.assertEqual(donation.state, "draft")
# Try to skip to 'stocked' state (should fail)
stocked_state = self.env["spp.vocabulary.code"].search(
@@ -446,10 +489,13 @@ def test_valid_state_transition_sequence(self):
}
)
- # Follow valid sequence: announced -> received -> inspected -> stocked
+ # Follow valid sequence: draft -> announced -> received -> inspected -> stocked
+ self.assertEqual(donation.state, "draft")
+
+ donation.action_mark_announced()
self.assertEqual(donation.state, "announced")
- donation.action_mark_received()
+ self._receive_donation(donation)
self.assertEqual(donation.state, "received")
donation.action_inspect()
@@ -483,7 +529,7 @@ def test_donation_workflow_reject(self):
donation.action_reject()
# Go through the workflow
- donation.action_mark_received()
+ self._receive_donation(donation)
self.assertEqual(donation.state, "received")
# Cannot reject from received state
@@ -521,7 +567,7 @@ def test_donation_workflow_reject_cancels_pickings(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
# Verify we have a picking in progress
@@ -555,6 +601,7 @@ def test_donation_cancel_from_announced(self):
],
}
)
+ donation.action_mark_announced()
self.assertEqual(donation.state, "announced")
donation.action_cancel()
self.assertEqual(donation.state, "cancelled")
@@ -579,7 +626,7 @@ def test_donation_cancel_from_received(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
self.assertEqual(donation.state, "received")
donation.action_cancel()
self.assertEqual(donation.state, "cancelled")
@@ -607,7 +654,7 @@ def test_donation_cancel_from_inspected(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
self.assertEqual(donation.state, "inspected")
donation.action_cancel()
@@ -633,7 +680,7 @@ def test_donation_cannot_cancel_stocked(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.action_stock()
self.assertEqual(donation.state, "stocked")
@@ -661,7 +708,7 @@ def test_donation_cannot_cancel_rejected(self):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.action_reject()
self.assertEqual(donation.state, "rejected")
@@ -692,6 +739,38 @@ def _make_donation(self, line_vals):
}
)
+ # ---------- OP#1163: Mark Received wizard ----------
+ def test_1163_receive_wizard_writes_qty_and_marks_received(self):
+ """OP#1163: the receive wizard pre-fills received from pledged, writes the
+ entered quantities back, and marks the donation received."""
+ donation = self._make_donation(
+ [{"product_id": self.product.id, "quantity_pledged": 10, "uom_id": self.product.uom_id.id}]
+ )
+ donation.action_mark_announced()
+
+ action = donation.action_open_receive_wizard()
+ self.assertEqual(action["res_model"], "spp.drims.receive.wizard")
+ wizard = self.env["spp.drims.receive.wizard"].browse(action["res_id"])
+ self.assertEqual(len(wizard.line_ids), 1)
+ # Received pre-filled from pledged.
+ self.assertEqual(wizard.line_ids.quantity_received, 10)
+
+ # Adjust and confirm.
+ wizard.line_ids.quantity_received = 8
+ wizard.action_confirm_received()
+ self.assertEqual(donation.state, "received")
+ self.assertEqual(donation.line_ids[0].quantity_received, 8)
+ self.assertEqual(donation.picking_count, 1)
+
+ def test_1163_receive_wizard_only_announced(self):
+ """OP#1163: opening the receive wizard on a non-announced donation raises."""
+ donation = self._make_donation(
+ [{"product_id": self.product.id, "quantity_pledged": 10, "uom_id": self.product.uom_id.id}]
+ )
+ # Still draft, not announced.
+ with self.assertRaises(UserError):
+ donation.action_open_receive_wizard()
+
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)")
@@ -705,7 +784,7 @@ def test_action_stock_creates_lot_for_lot_tracked_product(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.action_stock()
self.assertEqual(donation.state, "stocked")
@@ -732,7 +811,7 @@ def test_action_stock_sets_expiry_when_provided(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.action_stock()
lot = self.env["stock.lot"].search(
@@ -766,7 +845,7 @@ def test_action_stock_reuses_existing_lot(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.action_stock()
lots = self.env["stock.lot"].search([("name", "=", "LOT-RICE-EXISTING"), ("product_id", "=", product.id)])
@@ -786,7 +865,7 @@ def test_action_stock_serial_qty_one_succeeds(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.action_stock()
self.assertEqual(donation.state, "stocked")
@@ -804,7 +883,7 @@ def test_action_stock_serial_qty_gt_one_raises(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
with self.assertRaises(UserError):
donation.action_stock()
@@ -822,7 +901,7 @@ def test_action_stock_missing_lot_number_raises(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
with self.assertRaises(UserError):
donation.action_stock()
@@ -839,7 +918,7 @@ def test_action_stock_untracked_product_unaffected(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.action_stock()
self.assertEqual(donation.state, "stocked")
@@ -882,7 +961,7 @@ def test_action_stock_excludes_return_disposition(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.line_ids[0].disposition_id = disposition_return
@@ -913,7 +992,7 @@ def test_action_stock_excludes_dispose_disposition(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.line_ids[0].disposition_id = disposition_dispose
@@ -941,7 +1020,7 @@ def test_action_stock_mixed_dispositions_only_accepted_stocks(self):
},
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.line_ids[0].disposition_id = disposition_accept
donation.line_ids[1].disposition_id = disposition_return
@@ -954,6 +1033,38 @@ def test_action_stock_mixed_dispositions_only_accepted_stocks(self):
self.assertIsNotNone(result)
self.assertIn("200", result["params"]["message"])
+ def test_1058_accepted_and_non_accepted_line_split(self):
+ """OP#1058: a non-accepted line appears only in non_accepted_line_ids
+ (the "Items Not Accepted for Stock" table), not in accepted_line_ids
+ (the "Donation Items" table) — no duplication across the two tables."""
+ 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": 3, "uom_id": self.product.uom_id.id},
+ {"product_id": self.product.id, "quantity_pledged": 1, "uom_id": self.product.uom_id.id},
+ ]
+ )
+ self._receive_donation(donation)
+ donation.action_inspect()
+ accepted_line = donation.line_ids[0]
+ returned_line = donation.line_ids[1]
+ accepted_line.disposition_id = disposition_accept
+ returned_line.disposition_id = disposition_return
+
+ # The split covers every line, with no overlap.
+ self.assertIn(accepted_line, donation.accepted_line_ids)
+ self.assertNotIn(returned_line, donation.accepted_line_ids)
+ self.assertIn(returned_line, donation.non_accepted_line_ids)
+ self.assertNotIn(accepted_line, donation.non_accepted_line_ids)
+ self.assertEqual(
+ donation.accepted_line_ids | donation.non_accepted_line_ids,
+ donation.line_ids,
+ )
+
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
@@ -984,7 +1095,7 @@ def test_action_stock_mixed_dispositions_partial_receive_only_stocks_accept(self
},
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
# 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
@@ -1021,7 +1132,7 @@ def test_has_acceptable_items_all_non_accept(self):
},
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.line_ids[0].disposition_id = disposition_return
donation.line_ids[1].disposition_id = disposition_dispose
@@ -1052,7 +1163,7 @@ def test_has_acceptable_items_mixed(self):
},
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
donation.action_inspect()
donation.line_ids[0].disposition_id = disposition_accept
donation.line_ids[1].disposition_id = disposition_return
@@ -1074,10 +1185,348 @@ def test_action_stock_all_accept_unchanged(self):
}
]
)
- donation.action_mark_received()
+ self._receive_donation(donation)
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)
+
+
+@tagged("post_install", "-at_install")
+class TestDrimsDonationOP1076(DrimsTestCommon):
+ """OP#1076 — donation creation rules and the draft→announced lifecycle."""
+
+ def _draft_donation(self, **overrides):
+ vals = {
+ "incident_id": self.incident.id,
+ "warehouse_id": self.warehouse.id,
+ "donor_name": "Test Donor",
+ "line_ids": [
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 100, "uom_id": self.product.uom_id.id})
+ ],
+ }
+ vals.update(overrides)
+ return self.env["spp.drims.donation"].create(vals)
+
+ def test_default_state_is_draft(self):
+ """A new donation starts in the draft state (not announced)."""
+ self.assertEqual(self._draft_donation().state, "draft")
+
+ def test_mark_announced_transitions_draft_to_announced(self):
+ donation = self._draft_donation()
+ donation.action_mark_announced()
+ self.assertEqual(donation.state, "announced")
+
+ def test_mark_announced_only_from_draft(self):
+ donation = self._draft_donation()
+ donation.action_mark_announced()
+ with self.assertRaises(UserError):
+ donation.action_mark_announced()
+
+ def test_mark_received_requires_announced(self):
+ """Mark Received is not available before the donation is announced."""
+ donation = self._draft_donation()
+ with self.assertRaises(UserError):
+ donation.action_mark_received()
+
+ def test_mark_received_requires_received_qty(self):
+ """Received quantities must be entered before marking received."""
+ donation = self._draft_donation()
+ donation.action_mark_announced()
+ with self.assertRaises(UserError):
+ donation.action_mark_received()
+
+ def test_received_is_manual_not_autocopied(self):
+ """Received is taken from manual entry, not auto-copied from pledged."""
+ donation = self._draft_donation() # pledged 100
+ donation.action_mark_announced()
+ donation.line_ids[0].quantity_received = 40
+ donation.action_mark_received()
+ self.assertEqual(donation.state, "received")
+ self.assertEqual(donation.line_ids[0].quantity_received, 40)
+ self.assertEqual(donation.line_ids[0].receipt_variance, -60)
+ self.assertEqual(donation.picking_count, 1)
+
+ def test_pledged_must_be_positive(self):
+ with self.assertRaises(ValidationError):
+ self._draft_donation(
+ line_ids=[
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 0, "uom_id": self.product.uom_id.id})
+ ]
+ )
+
+ def test_at_least_one_line_required(self):
+ with self.assertRaises(ValidationError):
+ self.env["spp.drims.donation"].create(
+ {
+ "incident_id": self.incident.id,
+ "warehouse_id": self.warehouse.id,
+ "donor_name": "No lines",
+ }
+ )
+
+ def test_cannot_donate_to_closed_incident(self):
+ closed_incident = self.env["spp.hazard.incident"].create(
+ {
+ "name": "Closed Incident",
+ "code": "CLOSED-2026-TEST",
+ "category_id": self.hazard_category.id,
+ "start_date": "2024-01-01",
+ "status": "closed",
+ }
+ )
+ with self.assertRaises(ValidationError):
+ self._draft_donation(incident_id=closed_incident.id)
+
+ def test_non_accepted_lines_computed(self):
+ """Lines with a non-accept disposition surface in non_accepted_line_ids."""
+ disposition_return = self.env["spp.vocabulary.code"].search(
+ [
+ ("vocabulary_id.namespace_uri", "=", "urn:openspp:vocab:drims:item-dispositions"),
+ ("code", "=", "return"),
+ ],
+ limit=1,
+ )
+ if not disposition_return:
+ self.skipTest("return disposition vocab code missing")
+ donation = self._draft_donation()
+ self._receive_donation(donation)
+ donation.action_inspect()
+ self.assertFalse(donation.non_accepted_line_ids)
+ donation.line_ids[0].disposition_id = disposition_return
+ self.assertIn(donation.line_ids[0], donation.non_accepted_line_ids)
+
+ # ---------- OP#1055: items can be added/removed while draft or announced,
+ # but the list is locked once the donation is received ----------
+
+ def test_1055_can_add_and_remove_lines_in_draft(self):
+ donation = self._draft_donation()
+ line = self.env["spp.drims.donation.line"].create(
+ {
+ "donation_id": donation.id,
+ "product_id": self.product.id,
+ "quantity_pledged": 5,
+ "uom_id": self.product.uom_id.id,
+ }
+ )
+ self.assertIn(line, donation.line_ids)
+ line.unlink()
+ self.assertNotIn(line, donation.line_ids)
+
+ def test_1055_can_add_and_remove_lines_when_announced(self):
+ """OP#1055 (round 2): a line can still be added — with a selectable
+ product — while the donation is announced (QA adds late-announced items
+ during receiving). Adding/removing is allowed up to the announced state.
+ """
+ donation = self._draft_donation()
+ donation.action_mark_announced()
+ line = self.env["spp.drims.donation.line"].create(
+ {
+ "donation_id": donation.id,
+ "product_id": self.product.id,
+ "quantity_pledged": 5,
+ "uom_id": self.product.uom_id.id,
+ }
+ )
+ self.assertIn(line, donation.line_ids)
+ line.unlink()
+ self.assertNotIn(line, donation.line_ids)
+
+ def test_1055_cannot_add_line_after_received(self):
+ """The original bug: no new items once the donation is received."""
+ donation = self._draft_donation()
+ self._receive_donation(donation)
+ self.assertEqual(donation.state, "received")
+ with self.assertRaises(ValidationError):
+ self.env["spp.drims.donation.line"].create(
+ {
+ "donation_id": donation.id,
+ "product_id": self.product.id,
+ "quantity_pledged": 5,
+ "uom_id": self.product.uom_id.id,
+ }
+ )
+
+ def test_1055_cannot_remove_line_after_received(self):
+ donation = self._draft_donation()
+ self._receive_donation(donation)
+ with self.assertRaises(ValidationError):
+ donation.line_ids[0].unlink()
+
+ # ---------- OP#1108: inline-created products are storable ----------
+
+ def _donation_items_list(self):
+ """The items list on the donation form, identified by its own columns.
+
+ The form holds several lists — pickings and disposal follow-up among
+ them — and some carry a product_id of their own with no such context,
+ so a test that swept every list would fail on the wrong one.
+ """
+ arch = etree.fromstring(self.env.ref("spp_drims.view_drims_donation_form").arch)
+ for lst in arch.iter("list"):
+ if any(field.get("name") == "quantity_pledged" for field in lst.findall("field")):
+ return lst
+ self.fail("the donation form should have an items list with a Pledged column")
+
+ def test_1108_the_line_asks_for_a_storable_product(self):
+ """The defaults live on the field's context, so assert them there.
+
+ Building the context by hand exercises Odoo's defaulting machinery
+ rather than the fix: that version passed whether or not the view still
+ carried the context, which is the only thing OP#1108 changed
+ (OP#1076 review).
+ """
+ product = [f for f in self._donation_items_list().findall("field") if f.get("name") == "product_id"]
+
+ self.assertTrue(product, "the donation items list should offer a product")
+ context = product[0].get("context") or ""
+ self.assertIn("default_type", context, "a quick-created product must be a Good")
+ self.assertIn("'consu'", context)
+ self.assertIn("default_is_storable", context, "it must be trackable in inventory")
+
+ def test_1108_inline_product_defaults_to_storable(self):
+ """And the defaults the view asks for do produce a storable Good."""
+ product_model = self.env["product.product"].with_context(default_type="consu", default_is_storable=True)
+ pid, _name = product_model.name_create("QA Inline Donated Item 1108")
+ product = self.env["product.product"].browse(pid)
+ self.assertEqual(product.type, "consu")
+ self.assertTrue(product.is_storable)
+
+ def test_1076_received_quantity_may_be_zero_but_not_negative(self):
+ """Zero is a real answer — pledged and never arrived.
+
+ The view no longer marks Received required, because the web client
+ reads 0.0 on a float as "not set" and refused to save that line. What
+ must not pass is a negative, which slips through the "at least one line
+ above zero" rule whenever another line is positive and then fails deep
+ in the receipt picking (OP#1076 review).
+ """
+ donation = self._draft_donation()
+ donation.action_mark_announced()
+ line = donation.line_ids[0]
+
+ line.quantity_received = 0
+ self.assertEqual(line.quantity_received, 0, "a shortfall of the whole line must be recordable")
+
+ with self.assertRaises(ValidationError):
+ line.quantity_received = -1
+
+ def test_1076_received_is_not_required_by_the_list(self):
+ """Guards the attribute that made a legitimate zero unsaveable."""
+ received = [f for f in self._donation_items_list().findall("field") if f.get("name") == "quantity_received"]
+
+ self.assertTrue(received, "the donation items list should show Received")
+ self.assertIsNone(
+ received[0].get("required"),
+ "a required float cannot be saved as 0, which is a valid receipt",
+ )
+
+ # ---------- OP#1058: non-accepted items follow-up tracking ----------
+
+ 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 test_1058_excluded_item_pending_after_stock(self):
+ """Stocking flags non-accept items as Pending disposal; accepted ones aren't."""
+ disp_accept = self._disposition("accept")
+ disp_return = self._disposition("return")
+ if not (disp_accept and disp_return):
+ self.skipTest("required disposition codes missing")
+ donation = self._draft_donation(
+ line_ids=[
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 100, "uom_id": self.product.uom_id.id}),
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 40, "uom_id": self.product.uom_id.id}),
+ ]
+ )
+ self._receive_donation(donation)
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disp_accept
+ donation.line_ids[1].disposition_id = disp_return
+ donation.action_stock()
+
+ self.assertEqual(donation.state, "stocked")
+ self.assertFalse(donation.line_ids[0].disposal_state, "accepted line needs no disposal")
+ self.assertEqual(donation.line_ids[1].disposal_state, "pending")
+ self.assertTrue(donation.line_ids[1].is_non_accept)
+
+ def test_1058_mark_resolved_records_and_audits(self):
+ """Resolving a non-accept item records who/when and posts an audit note."""
+ disp_return = self._disposition("return")
+ if not disp_return:
+ self.skipTest("return disposition code missing")
+ donation = self._draft_donation()
+ self._receive_donation(donation)
+ donation.action_inspect()
+ donation.line_ids[0].disposition_id = disp_return
+ donation.action_stock()
+ line = donation.line_ids[0]
+ self.assertEqual(line.disposal_state, "pending")
+
+ messages_before = len(donation.message_ids)
+ line.disposal_notes = "Returned to donor on truck #5"
+ line.action_mark_disposal_resolved()
+
+ self.assertEqual(line.disposal_state, "resolved")
+ self.assertEqual(line.disposal_date, date.today())
+ self.assertEqual(line.disposal_user_id, self.env.user)
+ self.assertGreater(len(donation.message_ids), messages_before, "an audit note should be posted")
+
+ # ------------------------------------------------------------------
+ # round 2: the form has to let you enter items in the first place
+ # ------------------------------------------------------------------
+
+ def test_filler_row_css_does_not_hide_add_a_line(self):
+ """The stylesheet must hide Odoo's blank filler rows and nothing else.
+
+ It previously excluded ``.o_field_x2many_list_row_add`` from the rows it
+ hid, on the assumption that class sits on the ``
``. In Odoo 19 it is
+ on the ``
`` — the row is ``
`` — so the
+ exclusion never matched and the rule hid the "Add a line" row itself.
+ A new donation then offered no way to add items at all.
+
+ Filler rows are the only ones rendered without a class, which is what
+ the rule keys on now.
+ """
+ css = (Path(__file__).parents[1] / "static/src/css/donation_form.css").read_text()
+ # Comments explain the trap by name, so check the rules themselves.
+ rules = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
+
+ self.assertIn(
+ "tr:not([class])",
+ rules,
+ "filler rows should be matched by having no class",
+ )
+ self.assertNotIn(
+ "o_field_x2many_list_row_add",
+ rules,
+ "that class is on the
, not the
— excluding it on the row never matches "
+ "and the Add a line row gets hidden instead",
+ )
+
+ def test_donation_lists_use_column_invisible_not_invisible(self):
+ """Inside a list, ``invisible`` blanks the cells but keeps the column.
+
+ A field hidden that way leaves an empty titled column in the table —
+ which is how a stray "Quantity" column appeared between Pledged and
+ Unit on the donation items table.
+ """
+ arch = etree.fromstring(self.env.ref("spp_drims.view_drims_donation_form").arch)
+
+ offenders = [
+ field.get("name")
+ for lst in arch.iter("list")
+ for field in lst.findall("field")
+ if field.get("invisible") == "1"
+ ]
+ self.assertFalse(
+ offenders,
+ f"these list fields should use column_invisible instead of invisible: {offenders}",
+ )
diff --git a/spp_drims/tests/test_incident.py b/spp_drims/tests/test_incident.py
index b41ab019f..8732fbcb5 100644
--- a/spp_drims/tests/test_incident.py
+++ b/spp_drims/tests/test_incident.py
@@ -25,6 +25,9 @@ def test_incident_drims_donation_count(self):
"incident_id": self.incident.id,
"warehouse_id": self.warehouse.id,
"donor_name": "Test Donor",
+ "line_ids": [
+ (0, 0, {"product_id": self.product.id, "quantity_pledged": 10, "uom_id": self.product.uom_id.id})
+ ],
}
)
self.incident.invalidate_recordset()
diff --git a/spp_drims/tests/test_wizard.py b/spp_drims/tests/test_wizard.py
index 3de6c185b..d5518a9ec 100644
--- a/spp_drims/tests/test_wizard.py
+++ b/spp_drims/tests/test_wizard.py
@@ -331,7 +331,7 @@ def _create_received_donation(self, quantity=100):
],
}
)
- donation.action_mark_received()
+ self._receive_donation(donation)
return donation
def _open_inspection_wizard(self, donation):
diff --git a/spp_drims/views/donation_views.xml b/spp_drims/views/donation_views.xml
index b419fe085..97806cea0 100644
--- a/spp_drims/views/donation_views.xml
+++ b/spp_drims/views/donation_views.xml
@@ -42,7 +42,16 @@
+
+
+
+ spp.drims.donation.line.non.accepted.list
+ spp.drims.donation.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ spp.drims.donation.line.non.accepted.search
+ spp.drims.donation.line
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Non-Accepted Items
+ spp.drims.donation.line
+ list
+
+
+ [('disposal_state', '!=', False)]
+ {'search_default_pending': 1}
+
+
Nothing to follow up
+
+ Items excluded from stock during inspection (Return, Dispose or
+ Quarantine) appear here so their handling can be recorded and audited.
+
+
+
diff --git a/spp_drims/views/menus.xml b/spp_drims/views/menus.xml
index 4da8e1c65..c4b92ee79 100644
--- a/spp_drims/views/menus.xml
+++ b/spp_drims/views/menus.xml
@@ -151,6 +151,14 @@
sequence="30"
/>
+
+
diff --git a/spp_drims/wizard/__init__.py b/spp_drims/wizard/__init__.py
index 514fd529f..e538c301f 100644
--- a/spp_drims/wizard/__init__.py
+++ b/spp_drims/wizard/__init__.py
@@ -9,3 +9,4 @@
from . import request_from_template_wizard
from . import create_return_wizard
from . import inspection_wizard
+from . import receive_wizard
diff --git a/spp_drims/wizard/inspection_wizard.py b/spp_drims/wizard/inspection_wizard.py
index 2b75671f1..c666c695d 100644
--- a/spp_drims/wizard/inspection_wizard.py
+++ b/spp_drims/wizard/inspection_wizard.py
@@ -149,7 +149,9 @@ def action_confirm_inspection(self):
for line in lines[1:]:
if line.quantity <= 0:
continue
- DonationLine.create(
+ # OP#1055: split rows are legitimate system-created lines on an
+ # already-received donation — bypass the draft-only guard.
+ DonationLine.with_context(allow_donation_line_create=True).create(
{
"donation_id": self.donation_id.id,
"product_id": line.product_id.id,
diff --git a/spp_drims/wizard/receive_wizard.py b/spp_drims/wizard/receive_wizard.py
new file mode 100644
index 000000000..5e460cb9d
--- /dev/null
+++ b/spp_drims/wizard/receive_wizard.py
@@ -0,0 +1,69 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""DRIMS Donation Receive Wizard (OP#1163).
+
+"Mark Received" used to fail with a plain error when the received quantities
+had not been entered yet. This wizard (mirroring the Inspect Items flow) opens
+a single screen listing the donation's items with an editable Received column
+pre-filled from the pledged quantity, then writes the entered quantities back
+and marks the donation received.
+"""
+
+from odoo import _, fields, models
+from odoo.exceptions import UserError
+
+
+class DrimsReceiveWizard(models.TransientModel):
+ _name = "spp.drims.receive.wizard"
+ _description = "DRIMS Donation Receive Wizard"
+
+ donation_id = fields.Many2one(
+ "spp.drims.donation",
+ string="Donation",
+ required=True,
+ readonly=True,
+ )
+ donation_reference = fields.Char(
+ related="donation_id.reference",
+ string="Reference",
+ )
+ line_ids = fields.One2many(
+ "spp.drims.receive.wizard.line",
+ "wizard_id",
+ string="Received Items",
+ )
+
+ def action_confirm_received(self):
+ """Write the entered received quantities back to the donation lines,
+ then mark the donation received."""
+ self.ensure_one()
+ if not self.line_ids:
+ raise UserError(_("No items to receive."))
+ for wl in self.line_ids:
+ wl.donation_line_id.quantity_received = wl.quantity_received
+ # action_mark_received validates that at least one item has a received
+ # quantity > 0, sets the state and creates the receipt picking.
+ self.donation_id.action_mark_received()
+ return {"type": "ir.actions.act_window_close"}
+
+
+class DrimsReceiveWizardLine(models.TransientModel):
+ _name = "spp.drims.receive.wizard.line"
+ _description = "DRIMS Donation Receive Wizard Line"
+ _order = "id"
+
+ wizard_id = fields.Many2one(
+ "spp.drims.receive.wizard",
+ string="Wizard",
+ required=True,
+ ondelete="cascade",
+ )
+ donation_line_id = fields.Many2one(
+ "spp.drims.donation.line",
+ string="Donation Line",
+ required=True,
+ readonly=True,
+ )
+ product_id = fields.Many2one("product.product", string="Product", readonly=True)
+ uom_id = fields.Many2one("uom.uom", string="Unit", readonly=True)
+ quantity_pledged = fields.Float(string="Pledged", readonly=True)
+ quantity_received = fields.Float(string="Received")
diff --git a/spp_drims/wizard/receive_wizard_views.xml b/spp_drims/wizard/receive_wizard_views.xml
new file mode 100644
index 000000000..f308b09b5
--- /dev/null
+++ b/spp_drims/wizard/receive_wizard_views.xml
@@ -0,0 +1,49 @@
+
+
+
+
+ spp.drims.receive.wizard.form
+ spp.drims.receive.wizard
+
+
+
+
+
diff --git a/spp_drims_sl_demo/README.rst b/spp_drims_sl_demo/README.rst
index 07011511f..e9fd518a1 100644
--- a/spp_drims_sl_demo/README.rst
+++ b/spp_drims_sl_demo/README.rst
@@ -130,6 +130,13 @@ Dependencies
Changelog
=========
+19.0.2.1.0
+~~~~~~~~~~
+
+- chore(drims_sl_demo): generate donations through the reworked
+ lifecycle — demo donations carry the pledged quantities and states the
+ new donation flow expects (#1076)
+
19.0.2.0.0
~~~~~~~~~~
diff --git a/spp_drims_sl_demo/__manifest__.py b/spp_drims_sl_demo/__manifest__.py
index 17074375b..1401b4a50 100644
--- a/spp_drims_sl_demo/__manifest__.py
+++ b/spp_drims_sl_demo/__manifest__.py
@@ -4,7 +4,7 @@
"summary": "Demo data generator for DRIMS Sri Lanka implementation. "
"Creates sample incidents, donations, requests, and stock for demonstrations.",
"category": "OpenSPP/Inventory",
- "version": "19.0.2.0.0",
+ "version": "19.0.2.1.0",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
diff --git a/spp_drims_sl_demo/readme/HISTORY.md b/spp_drims_sl_demo/readme/HISTORY.md
index 4aaf9afef..f71b94114 100644
--- a/spp_drims_sl_demo/readme/HISTORY.md
+++ b/spp_drims_sl_demo/readme/HISTORY.md
@@ -1,3 +1,7 @@
+### 19.0.2.1.0
+
+- chore(drims_sl_demo): generate donations through the reworked lifecycle — demo donations carry the pledged quantities and states the new donation flow expects (#1076)
+
### 19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_drims_sl_demo/static/description/index.html b/spp_drims_sl_demo/static/description/index.html
index f0a2f58e5..47f0e8540 100644
--- a/spp_drims_sl_demo/static/description/index.html
+++ b/spp_drims_sl_demo/static/description/index.html
@@ -503,6 +503,14 @@
chore(drims_sl_demo): generate donations through the reworked
+lifecycle — demo donations carry the pledged quantities and states the
+new donation flow expects (#1076)
+
+
+
19.0.2.0.0
Initial migration to OpenSPP2
diff --git a/spp_drims_sl_demo/wizard/drims_demo_generator.py b/spp_drims_sl_demo/wizard/drims_demo_generator.py
index deda0fc0a..c49f44d1f 100644
--- a/spp_drims_sl_demo/wizard/drims_demo_generator.py
+++ b/spp_drims_sl_demo/wizard/drims_demo_generator.py
@@ -941,15 +941,28 @@ def _generate_donation_lines(self, products):
def _progress_donation_state(self, donation, target_state):
"""Progress donation through states.
- Actual workflow: announced → received → inspected → stocked
+ Workflow: draft → announced → received → inspected → stocked
+
+ OP#1076 made ``draft`` the state a donation is created in; it used to
+ start at ``announced``. Walking from draft means the announce step has
+ to be taken explicitly, otherwise Mark Received refuses with "Only
+ announced donations can be marked as received."
"""
- states = ["announced", "received", "inspected", "stocked"]
- current_idx = 0 # Start at announced (default state)
+ states = ["draft", "announced", "received", "inspected", "stocked"]
+ current_idx = 0 # Start at draft (default state)
target_idx = states.index(target_state) if target_state in states else 0
while current_idx < target_idx:
current_idx += 1
- if states[current_idx] == "received":
+ if states[current_idx] == "announced":
+ donation.action_mark_announced()
+ elif states[current_idx] == "received":
+ # OP#1076: the received quantity is entered by hand on the
+ # announced donation and is required before Mark Received — it
+ # is no longer copied from the pledged quantity. Demo data
+ # records everything as arriving in full.
+ for line in donation.line_ids:
+ line.quantity_received = line.quantity_pledged
donation.action_mark_received()
elif states[current_idx] == "inspected":
donation.action_inspect()