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