From 6148b1bbc821e04041726253f68ba69e39f9c48c Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Mon, 18 May 2026 11:59:59 +0800 Subject: [PATCH 01/26] fix(spp_drims): bundled round-1 fixes for DRIMS tracker (#651) - #967: require beneficiary_count via the view (asterisk + pre-save check) - #961: auto-create stock.lot from donation_line.lot_number on action_stock, with friendly UserErrors for missing-lot + serial-qty>1; expose Lot/Batch + Expiry Date columns by default on donation items - #964: fall back to quantity_pledged when quantity_received is 0; relax wizard equality check to splits-only so a single line is treated as the user reporting the final received quantity - #968: hide New + Delete on the DRIMS Dispatches list via a primary-mode inherited view pinned to the action - #1026: wire the donation form's Inspect button to action_open_inspection_wizard instead of the raw action_inspect state-flip --- spp_drims/models/donation.py | 88 +++++++++++- spp_drims/tests/test_donation.py | 175 ++++++++++++++++++++++++ spp_drims/tests/test_wizard.py | 73 ++++++++-- spp_drims/views/donation_views.xml | 12 +- spp_drims/views/stock_picking_views.xml | 45 +++++- spp_drims/wizard/inspection_wizard.py | 31 ++++- 6 files changed, 394 insertions(+), 30 deletions(-) diff --git a/spp_drims/models/donation.py b/spp_drims/models/donation.py index 518ba8fa..8476afb4 100644 --- a/spp_drims/models/donation.py +++ b/spp_drims/models/donation.py @@ -404,17 +404,24 @@ def action_open_inspection_wizard(self): } ) - # Create all line records with default values (New/Accept) + # 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, + "quantity_expected": expected_qty, + "quantity": expected_qty, "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) @@ -440,10 +447,18 @@ 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 + 3. 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 + 4. Items become available in warehouse inventory 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,6 +467,8 @@ def action_stock(self): ], limit=1, ) + Lot = self.env["stock.lot"] + has_expiration = "expiration_date" in Lot._fields for rec in self: if rec.state != DONATION_STATE_INSPECTED: raise UserError(_("Only inspected donations can be marked as stocked.")) @@ -459,10 +476,73 @@ def action_stock(self): # Validate the picking to complete the receipt for picking in rec.picking_ids.filtered(lambda p: p.state not in ("done", "cancel")): picking.action_assign() + rec._assign_lots_to_picking(picking, Lot, has_expiration) for move in picking.move_ids: move.quantity = move.product_uom_qty picking.button_validate() + 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/tests/test_donation.py b/spp_drims/tests/test_donation.py index 15bf5c7c..d567c5bd 100644 --- a/spp_drims/tests/test_donation.py +++ b/spp_drims/tests/test_donation.py @@ -668,3 +668,178 @@ 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") diff --git a/spp_drims/tests/test_wizard.py b/spp_drims/tests/test_wizard.py index cb60da68..361f300d 100644 --- a/spp_drims/tests/test_wizard.py +++ b/spp_drims/tests/test_wizard.py @@ -355,28 +355,83 @@ def test_inspection_wizard_validation_uninspected(self): 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. Confirming with a different quantity + than the wizard's expected hint should succeed and update + ``quantity_received`` on the donation line accordingly. + """ 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) - # Manually change quantity to create mismatch - wizard.line_ids[0].write( + wizard.line_ids[0].write({"quantity": 800}) + + self.assertTrue(wizard.is_valid, "single-line wizard should always be valid") + wizard.action_confirm_inspection() + self.assertEqual(donation.state, "inspected") + self.assertEqual(donation.line_ids[0].quantity_received, 800) + + def test_inspection_wizard_split_mismatch_still_raises(self): + """OP#964: when the user splits a product across multiple lines, + the totals must still sum to the expected quantity — that + safety net is preserved. + """ + 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) + + # Two lines that sum to 950 ≠ 1000 received → should raise + wizard.line_ids[0].write({"quantity": 800}) + self.env["spp.drims.inspection.wizard.line"].create( { - "quantity": 800, # Less than expected 1000 + "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": 150, # 800 + 150 = 950 ≠ 1000 + "condition_id": self.condition_damaged.id, + "disposition_id": self.disposition_return.id, + "is_inspected": True, } ) - # is_valid should be False self.assertFalse(wizard.is_valid) - - # 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: when a donation line has quantity_received = 0 (e.g., it + was added after action_mark_received ran, or pledged was 0), the + wizard should fall back to quantity_pledged so the user can + still finalize inspection. Reproduces the bug from the original + screenshot. + """ + if not self.condition_new or not self.disposition_accept: + self.skipTest("Required vocabulary codes not found") + + donation = self._create_received_donation(quantity=500) + # Simulate the bug: line added with pledged=500 but received=0 + # (because action_mark_received already ran for the original lines). + donation.line_ids[0].quantity_received = 0 + + wizard = self._open_inspection_wizard(donation) + # Wizard line should pick up pledged (500), not received (0) + self.assertEqual(wizard.line_ids[0].quantity_expected, 500) + self.assertTrue(wizard.is_valid) + + 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.""" diff --git a/spp_drims/views/donation_views.xml b/spp_drims/views/donation_views.xml index 3dbfffd1..7f9badb9 100644 --- a/spp_drims/views/donation_views.xml +++ b/spp_drims/views/donation_views.xml @@ -49,8 +49,8 @@ invisible="state != 'announced'" /> - - - - - - + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + @@ -130,6 +161,8 @@ + + + + + + + + + + + + stock.warehouse.gis.drims + stock.warehouse + + + + + + + + + + + + + + + + + Warehouse Location + + + basic + + 0.9 + #1F78B4 + + + + osm + Default + + + DRIMS Warehouses From 93753a49f60154835b825620a97f537e812d9f85 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Fri, 22 May 2026 12:33:48 +0800 Subject: [PATCH 10/26] fix(spp_drims): donation statusbar is read-only display, not clickable (#960) The statusbar widget was configured with options="{'clickable': '1'}" which let users click a state badge to fire the corresponding write to state_id directly. That bypasses the header buttons (Mark Received / Inspect Items / Stock Items / Reject / Cancel) which run validation, trigger side-effects (auto-create stock picking on receive, open the inspection wizard, etc.), and enforce the state-machine transitions. Dropping the clickable option turns the statusbar into a read-only display indicator and forces state changes through the proper actions. --- spp_drims/views/donation_views.xml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/spp_drims/views/donation_views.xml b/spp_drims/views/donation_views.xml index dc147923..608167e7 100644 --- a/spp_drims/views/donation_views.xml +++ b/spp_drims/views/donation_views.xml @@ -79,11 +79,14 @@ invisible="state in ('stocked', 'rejected', 'cancelled')" confirm="Cancel this donation? Any pending transfers will be cancelled." /> - + +
From 3546fdf047c68822e1275f2ed850f0cf7162b2f9 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Fri, 22 May 2026 15:42:56 +0800 Subject: [PATCH 11/26] fix(spp_drims): block empty allocation and keep request at Ready for Allocation (#1032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user allocated an approved request against a warehouse with zero available stock, _allocate_stock_fifo was a no-op (0 across all lines) but action_allocate still advanced the request to allocated (Ready for Dispatch). The Create Dispatch button then became available against a request with nothing allocated, allowing an empty / invalid stock picking to be created. Same path through the allocation-preview wizard: action_confirm_allocation would write 0 across all request lines and still advance state. Both paths now raise a UserError with the message: "No stock available in the selected warehouse. Please ensure the source warehouse has sufficient items before allocating." when the total allocation is 0. The request state stays at approved (Ready for Allocation) so the user can pick a different warehouse. Partial allocation (some lines have stock, others don't) is still allowed — the check is total <= 0. Updated test_allocate_with_warehouse to seed stock first (otherwise it would now fail). Added test_allocate_blocked_when_warehouse_has_no_stock and test_confirm_blocked_when_zero_stock for the negative cases. 230 spp_drims tests pass. --- spp_drims/models/request.py | 16 ++++++ .../tests/test_allocation_preview_wizard.py | 23 +++++++++ spp_drims/tests/test_request.py | 50 ++++++++++++++++++- spp_drims/wizard/allocation_preview_wizard.py | 14 ++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/spp_drims/models/request.py b/spp_drims/models/request.py index 61d1a097..8e15dc9e 100644 --- a/spp_drims/models/request.py +++ b/spp_drims/models/request.py @@ -511,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": @@ -518,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( [ diff --git a/spp_drims/tests/test_allocation_preview_wizard.py b/spp_drims/tests/test_allocation_preview_wizard.py index 6dfff6f8..ef1c8af7 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 diff --git a/spp_drims/tests/test_request.py b/spp_drims/tests/test_request.py index 7bc5de45..e10eb8c7 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).""" diff --git a/spp_drims/wizard/allocation_preview_wizard.py b/spp_drims/wizard/allocation_preview_wizard.py index a5c30422..ec13fc47 100644 --- a/spp_drims/wizard/allocation_preview_wizard.py +++ b/spp_drims/wizard/allocation_preview_wizard.py @@ -182,6 +182,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, From ce555c229a9a037400f9a79b7f447d5c8a82496e Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Mon, 25 May 2026 10:54:07 +0800 Subject: [PATCH 12/26] feat(drims): redesign inspection wizard with inline editing and splits (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the read-only-list + per-item popup flow with a single inline-editable list. Condition and Action are picked directly on each row; "+ Add split" creates an indented child row for products with mixed conditions. The parent row of a split carries no decision — its qty mirrors the running sum of its children — and the old InspectionItemWizard / popup view are removed entirely. --- spp_drims/__manifest__.py | 2 + spp_drims/models/donation.py | 34 +- spp_drims/security/ir.model.access.csv | 3 - .../static/src/css/inspection_wizard.css | 11 + .../static/src/js/inspection_list_renderer.js | 16 + spp_drims/tests/test_wizard.py | 370 +++++++++-------- spp_drims/wizard/inspection_wizard.py | 384 ++++++------------ spp_drims/wizard/inspection_wizard_views.xml | 192 ++++----- 8 files changed, 453 insertions(+), 559 deletions(-) create mode 100644 spp_drims/static/src/css/inspection_wizard.css create mode 100644 spp_drims/static/src/js/inspection_list_renderer.js diff --git a/spp_drims/__manifest__.py b/spp_drims/__manifest__.py index 246f11b7..9f2715b3 100644 --- a/spp_drims/__manifest__.py +++ b/spp_drims/__manifest__.py @@ -84,6 +84,8 @@ "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/css/inspection_wizard.css", ], }, "application": True, diff --git a/spp_drims/models/donation.py b/spp_drims/models/donation.py index 8476afb4..ab069076 100644 --- a/spp_drims/models/donation.py +++ b/spp_drims/models/donation.py @@ -15,8 +15,6 @@ VOCAB_DONATION_STATES, VOCAB_DONOR_TYPES, VOCAB_DRIMS_TYPES, - VOCAB_ITEM_CONDITIONS, - VOCAB_ITEM_DISPOSITIONS, VOCAB_RESTRICTIONS, ) @@ -362,13 +360,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,30 +376,12 @@ 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 @@ -422,9 +399,6 @@ def action_open_inspection_wizard(self): "uom_id": donation_line.uom_id.id, "quantity_expected": expected_qty, "quantity": expected_qty, - "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) } ) diff --git a/spp_drims/security/ir.model.access.csv b/spp_drims/security/ir.model.access.csv index 67b59024..d657ce6e 100644 --- a/spp_drims/security/ir.model.access.csv +++ b/spp_drims/security/ir.model.access.csv @@ -161,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 00000000..4261c354 --- /dev/null +++ b/spp_drims/static/src/css/inspection_wizard.css @@ -0,0 +1,11 @@ +/* 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 so the parent/child relationship is obvious. */ +.o_drims_inspection_list .o_data_row.o_is_split td:first-child { + padding-left: 2rem; +} 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 00000000..8b7c9ccf --- /dev/null +++ b/spp_drims/static/src/js/inspection_list_renderer.js @@ -0,0 +1,16 @@ +/** @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" && + record.data.is_split + ) { + return `${base} o_is_split`; + } + return base; + }, +}); diff --git a/spp_drims/tests/test_wizard.py b/spp_drims/tests/test_wizard.py index 174736dd..9d3e41a6 100644 --- a/spp_drims/tests/test_wizard.py +++ b/spp_drims/tests/test_wizard.py @@ -179,9 +179,7 @@ def test_request_reject_wizard_writes_reason_and_rejects(self): ) wizard.action_reject() self.assertEqual(request.approval_state, "rejected") - self.assertEqual( - request.rejection_reason, "Out of scope for this funding cycle" - ) + 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.""" @@ -290,208 +288,153 @@ 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_single_line_quantity_overrides_received(self): """OP#964: a single inspection line is treated as the user reporting - the final received quantity. Confirming with a different quantity - than the wizard's expected hint should succeed and update - ``quantity_received`` on the donation line accordingly. + 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 - wizard.line_ids[0].write({"quantity": 800}) - - self.assertTrue(wizard.is_valid, "single-line wizard should always be valid") wizard.action_confirm_inspection() self.assertEqual(donation.state, "inspected") self.assertEqual(donation.line_ids[0].quantity_received, 800) def test_inspection_wizard_split_mismatch_still_raises(self): - """OP#964: when the user splits a product across multiple lines, - the totals must still sum to the expected quantity — that - safety net is preserved. - """ + """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) - original_line = donation.line_ids[0] wizard = self._open_inspection_wizard(donation) - - # Two lines that sum to 950 ≠ 1000 received → should raise - wizard.line_ids[0].write({"quantity": 800}) - self.env["spp.drims.inspection.wizard.line"].create( + parent = wizard.line_ids[0] + + # Create a split via the real button so parent/child wiring is correct. + parent.action_add_split() + children = wizard.line_ids.filtered("is_split") + self.assertEqual(len(children), 1) + child_a = children[0] + + # Deliberately make the totals not match expected. + child_a.quantity = 800 + InspectionLine = self.env["spp.drims.inspection.wizard.line"] + child_b = InspectionLine.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, + "donation_line_id": parent.donation_line_id.id, + "product_id": parent.product_id.id, + "uom_id": parent.uom_id.id, + "quantity_expected": parent.quantity_expected, "quantity": 150, # 800 + 150 = 950 ≠ 1000 - "condition_id": self.condition_damaged.id, - "disposition_id": self.disposition_return.id, - "is_inspected": True, + "parent_line_id": parent.id, } ) + self._set_inspection(child_a, self.condition_new, self.disposition_accept) + self._set_inspection(child_b, self.condition_damaged, self.disposition_return) - self.assertFalse(wizard.is_valid) with self.assertRaises(UserError) as cm: wizard.action_confirm_inspection() self.assertIn("Splits must sum", str(cm.exception)) def test_inspection_wizard_quantity_received_zero_uses_pledged(self): - """OP#964: when a donation line has quantity_received = 0 (e.g., it - was added after action_mark_received ran, or pledged was 0), the - wizard should fall back to quantity_pledged so the user can - still finalize inspection. Reproduces the bug from the original - screenshot. - """ + """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) - # Simulate the bug: line added with pledged=500 but received=0 - # (because action_mark_received already ran for the original lines). donation.line_ids[0].quantity_received = 0 wizard = self._open_inspection_wizard(donation) - # Wizard line should pick up pledged (500), not received (0) - self.assertEqual(wizard.line_ids[0].quantity_expected, 500) - self.assertTrue(wizard.is_valid) + 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") @@ -499,7 +442,6 @@ def test_inspection_wizard_quantity_received_zero_uses_pledged(self): 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, @@ -517,73 +459,157 @@ 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_child_line(self): + """OP#963: action_add_split creates a child with parent_line_id set + and resets the parent qty to mirror the children.""" + 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() + self.assertEqual(len(wizard.line_ids), 2) + + children = wizard.line_ids.filtered("is_split") + self.assertEqual(len(children), 1) + child = children[0] + self.assertEqual(child.parent_line_id, parent) + # First child gets the full remaining (= expected on first split). + self.assertEqual(child.quantity, 1000) + # Parent qty mirrors the child running total. + self.assertEqual(parent.quantity, 1000) + self.assertTrue(parent.has_splits) + + def test_add_split_remaining_quantity(self): + """OP#963: subsequent splits get expected - already-allocated.""" + 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") + children[0].quantity = 700 # leaves 300 remaining + + parent.action_add_split() + children = wizard.line_ids.filtered("is_split") + self.assertEqual(len(children), 2) + new_child = children.sorted(key=lambda line: line.id)[-1] + self.assertEqual(new_child.quantity, 300) - # Starts inspected with defaults - self.assertIn(self.condition_new.display, line.condition_display) - self.assertIn(self.disposition_accept.display, line.condition_display) + def test_add_split_blocks_when_nothing_remaining(self): + """OP#963: splitting again with no remaining qty raises.""" + donation = self._create_received_donation(quantity=1000) + wizard = self._open_inspection_wizard(donation) + parent = wizard.line_ids[0] - # After marking as not inspected - line.write({"is_inspected": False}) - self.assertEqual(line.condition_display, "Not inspected") + parent.action_add_split() + # The single child already covers the full expected qty. + with self.assertRaises(UserError) as cm: + parent.action_add_split() + self.assertIn("No remaining quantity to split", str(cm.exception)) - def test_inspection_confirms_and_creates_splits(self): - """Test full flow: inspect with splits and confirm.""" + 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] + + parent.action_add_split() + children = wizard.line_ids.filtered("is_split") + self.assertEqual(len(children), 1) + children[0].action_remove_split() + + # Parent is the only line left, back to expected qty. + self.assertEqual(len(wizard.line_ids), 1) + self.assertEqual(parent.quantity, 1000) + self.assertFalse(parent.has_splits) + + 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] - # Change first line: 800 good (already defaults to New/Accept) - wizard.line_ids[0].write( + parent.action_add_split() + children = wizard.line_ids.filtered("is_split") + child_a = children[0] + child_a.quantity = 700 + + InspectionLine = self.env["spp.drims.inspection.wizard.line"] + child_b = InspectionLine.create( { - "quantity": 800, + "wizard_id": wizard.id, + "donation_line_id": parent.donation_line_id.id, + "product_id": parent.product_id.id, + "uom_id": parent.uom_id.id, + "quantity_expected": parent.quantity_expected, + "quantity": 300, + "parent_line_id": parent.id, } ) - # Add second line: 200 damaged - self.env["spp.drims.inspection.wizard.line"].create( + # 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") + + 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") + + donation = self._create_received_donation(quantity=1000) + wizard = self._open_inspection_wizard(donation) + parent = wizard.line_ids[0] + + parent.action_add_split() + children = wizard.line_ids.filtered("is_split") + child_a = children[0] + child_a.quantity = 800 + + InspectionLine = self.env["spp.drims.inspection.wizard.line"] + child_b = InspectionLine.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, + "donation_line_id": parent.donation_line_id.id, + "product_id": parent.product_id.id, + "uom_id": parent.uom_id.id, + "quantity_expected": parent.quantity_expected, "quantity": 200, - "condition_id": self.condition_damaged.id, - "disposition_id": self.disposition_return.id, - "is_inspected": True, + "parent_line_id": parent.id, } ) + self._set_inspection(child_a, self.condition_new, self.disposition_accept) + self._set_inspection(child_b, self.condition_damaged, self.disposition_return) - # Validation should pass - self.assertTrue(wizard.is_valid) - - # 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) @@ -592,7 +618,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") @@ -600,7 +626,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/wizard/inspection_wizard.py b/spp_drims/wizard/inspection_wizard.py index 468ec063..d579682f 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,90 +49,6 @@ class InspectionWizard(models.TransientModel): string="Inspection Notes", help="General notes about the inspection", ) - is_valid = fields.Boolean( - string="Is Valid", - compute="_compute_is_valid", - ) - - @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. - - OP#964: the expected-equals-total check only applies when the - user has split a product into multiple inspection lines. A single - line per product is treated as the user reporting the final - received quantity (which may differ from the pre-inspection - ``quantity_received`` hint), so we don't block them on equality. - """ - 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 split totals match per product (only when actually split) - 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, - "count": 0, - } - products[product_id]["total"] += line.quantity - products[product_id]["count"] += 1 - - wizard.is_valid = all( - data["count"] <= 1 or abs(data["expected"] - data["total"]) < 0.001 for data in products.values() - ) - - 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.""" @@ -141,17 +57,23 @@ 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 + # Group lines per product for the equality safety net + downstream writes. + # Parent rows are skipped here because their qty mirrors their children's + # running total — including them would double-count. products = {} for line in self.line_ids: + if line.has_splits: + continue product_id = line.product_id.id if product_id not in products: products[product_id] = { @@ -192,12 +114,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(): 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( @@ -209,7 +129,6 @@ def action_confirm_inspection(self): } ) - # Additional lines create new donation lines (splits) for line in lines[1:]: if line.quantity <= 0: continue @@ -229,7 +148,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), @@ -249,7 +167,7 @@ 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" @@ -265,6 +183,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", @@ -298,141 +221,111 @@ class InspectionWizardLine(models.TransientModel): ) is_inspected = fields.Boolean( string="Inspected", - default=False, - help="Whether this item has been inspected", + compute="_compute_is_inspected", + help="True once both Condition and Action are filled.", + ) + is_split = fields.Boolean( + string="Is Split Line", + compute="_compute_split_flags", + ) + has_splits = fields.Boolean( + string="Has Split Lines", + compute="_compute_split_flags", ) - 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", "wizard_id.line_ids", "wizard_id.line_ids.parent_line_id") + def _compute_split_flags(self): + for line in self: + line.is_split = bool(line.parent_line_id) + line.has_splits = any(sibling.parent_line_id == line for sibling in line.wizard_id.line_ids) + + @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: - 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_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.""" + 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 + ) + total = sum(siblings.mapped("quantity")) + (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( + # 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) and clear any Condition / Action the + # operator may have set before deciding to split — the parent row + # carries no decision; the children do. + if not child_lines: + root_line.quantity = 0 + root_line.condition_id = False + root_line.disposition_id = False + + self.env["spp.drims.inspection.wizard.line"].create( { - "quantity": self.quantity, - "condition_id": self.condition_id.id, - "disposition_id": self.disposition_id.id, - "notes": self.notes, - "is_inspected": True, + "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": remaining, + "parent_line_id": root_line.id, } ) - # Return to main wizard + # Keep the parent row's stored qty in sync after creating the child. + # Without this the parent row keeps the value it had before the child + # was added (0 on the first split, or the previous running total). + self._refresh_parent_quantity(root_line) + return { "type": "ir.actions.act_window", "res_model": "spp.drims.inspection.wizard", @@ -441,39 +334,32 @@ 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 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 + self.unlink() + + remaining_children = root_line.wizard_id.line_ids.filtered(lambda line: line.parent_line_id == root_line) + if not remaining_children: + # Reset parent back to the full expected quantity. + root_line.quantity = root_line.quantity_expected + else: + self._refresh_parent_quantity(root_line) - 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, - } - ) - - # 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 - } - ) + return { + "type": "ir.actions.act_window", + "res_model": "spp.drims.inspection.wizard", + "res_id": wizard_id, + "view_mode": "form", + "target": "new", + } - # Open edit popup for the new split - return new_line.action_edit_item() + @staticmethod + def _refresh_parent_quantity(parent): + """Recompute the parent qty as the sum of its child rows.""" + children = parent.wizard_id.line_ids.filtered(lambda line: line.parent_line_id == parent) + parent.quantity = sum(children.mapped("quantity")) diff --git a/spp_drims/wizard/inspection_wizard_views.xml b/spp_drims/wizard/inspection_wizard_views.xml index 553f18db..424a9a2e 100644 --- a/spp_drims/wizard/inspection_wizard_views.xml +++ b/spp_drims/wizard/inspection_wizard_views.xml @@ -16,44 +16,105 @@
- - - - - - + + + - +