diff --git a/stock_location_flowable/README.rst b/stock_location_flowable/README.rst new file mode 100644 index 000000000..437534fbc --- /dev/null +++ b/stock_location_flowable/README.rst @@ -0,0 +1,66 @@ +======================= +Stock Location Flowable +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:54ad28ddc49473710e7dd86ba1a9dfbf22c66c1f791fd97a5a03da055b977589 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-NuoBiT%2Fodoo--addons-lightgray.png?logo=github + :target: https://github.com/NuoBiT/odoo-addons/tree/18.0/stock_location_flowable + :alt: NuoBiT/odoo-addons + +|badge1| |badge2| |badge3| + +- Customizations that allow organizing, controlling, and mixing bulk + liquid and solid products in a location. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* NuoBiT Solutions SL + +Contributors +------------ + +- `NuoBiT `__: + + - Frank Cespedes fcespedes@nuobit.com + - Deniz Gallo dgallo@nuobit.com + - Bijaya Kumal bkumal@nuobit.com + - Eric Antones eantones@nuobit.com + +Maintainers +----------- + +This module is part of the `NuoBiT/odoo-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/stock_location_flowable/__init__.py b/stock_location_flowable/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/stock_location_flowable/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_location_flowable/__manifest__.py b/stock_location_flowable/__manifest__.py new file mode 100644 index 000000000..fd4910a92 --- /dev/null +++ b/stock_location_flowable/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "Stock Location Flowable", + "summary": "Customizations that allow organizing, controlling, and" + " mixing bulk liquid and solid products in a location", + "version": "18.0.1.0.1", + "author": "NuoBiT Solutions SL", + "website": "https://github.com/NuoBiT/odoo-addons", + "category": "Stock", + "depends": ["mrp", "uom_rounding_coherence"], + "license": "AGPL-3", + "data": [ + "views/stock_location_views.xml", + "views/stock_picking_views.xml", + "views/stock_picking_type_views.xml", + "views/mrp_production_views.xml", + "views/stock_move_views.xml", + ], +} diff --git a/stock_location_flowable/doc/diagrams/00_tank_lifecycle.svg b/stock_location_flowable/doc/diagrams/00_tank_lifecycle.svg new file mode 100644 index 000000000..e19cde451 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/00_tank_lifecycle.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + Tank Lifecycle — One Tank, Four States + This is ONE tank (flowable location) going through states over time. + A reception never reserves — it just delivers stock to the tank. + + + + Tank (flowable location) + e.g. Tank-A, Tank-B + + + + TIME → + + + + AVAILABLE + No active MO + No blocking + Tank may have stock + or be empty + + + + PO reception + picking validated + + + + + BLOCKED 🔒 + Stock arrived + MO created + No one else can receive + + + + MO mixes / + consumes + + + + + PROCESSING + MO consuming stock + Still blocked (MO active) + + + + MO marked + done + + + + AVAILABLE + MO completed + Blocking cleared + Ready for next + PO reception + + + + cycle repeats with next PO reception + + + + picking + + MO + + move.write + diff --git a/stock_location_flowable/doc/diagrams/01_first_reception.svg b/stock_location_flowable/doc/diagrams/01_first_reception.svg new file mode 100644 index 000000000..e7f5ce461 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/01_first_reception.svg @@ -0,0 +1,58 @@ + + + + + + + + + Scenario 1: First PO Reception — Happy Path + One PO received at an available tank. MO auto-created, location blocked, MO completes, location freed. + Everything works correctly. No bugs involved. + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception picking + + + Reception #1 Validated + MO created, location BLOCKED + + + MO #1 Processing + mixing / consuming + + + MO #1 Done + location UNBLOCKED + + + + AVAILABLE + + BLOCKED (MO #1) + + AVAILABLE + TANK LOCATION STATE + + + Blocking is set inside stock.move.write() when production.action_assign() changes the MO's raw move state. + Unblocking is set inside stock.move.write() when the MO's raw move goes to "done". + A reception never reserves — it delivers stock from a virtual supplier location to the tank. + diff --git a/stock_location_flowable/doc/diagrams/02_second_reception_blocked.svg b/stock_location_flowable/doc/diagrams/02_second_reception_blocked.svg new file mode 100644 index 000000000..61dd8217b --- /dev/null +++ b/stock_location_flowable/doc/diagrams/02_second_reception_blocked.svg @@ -0,0 +1,73 @@ + + + + + + + + + Scenario 2 — Bug #1: Second PO Reception — Location Blocked but Constraint Bypassed + Location IS blocked. A second PO receives into the same tank. The constraint SKIPS non-MO moves. + The second reception goes through. Two active MOs on the same tank. + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception #1 + + + + Reception #1 Validated + MO created, BLOCKED + + + time passes + + + + PO #2 Confirmed + creates reception #2 + + + + Reception #2 GOES THROUGH! + + + constraint skips non-MO moves + MO #2 created — TWO MOs! + + + !! + + + + AVAILABLE + + BLOCKED (MO #1) — but constraint doesn't enforce it for non-MO moves + TANK LOCATION STATE + + + + Bug #1: constraint checked the MOVE's production, not the LOCATION's blocking state + Old code: "if production and location.flowable_production_id != production" — when production is False + (PO reception, internal transfer), the entire check is skipped. Even though the location IS blocked. + + + + Fixed by PR #847: github.com/nuobit/odoo-addons/pull/847 + diff --git a/stock_location_flowable/doc/diagrams/03_second_reception_not_blocked.svg b/stock_location_flowable/doc/diagrams/03_second_reception_not_blocked.svg new file mode 100644 index 000000000..4e5b92439 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/03_second_reception_not_blocked.svg @@ -0,0 +1,74 @@ + + + + + + + + + Scenario 3 — Bug #2: Second PO Reception — Location Not Blocked + First reception's production.action_assign() failed → location was never blocked. Second PO goes through. + Two active MOs on the same tank. This is an example case. + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception #1 + + + + Reception #1 Validated + + MO created, NOT BLOCKED + + + 45 days pass + MO #1 never completed + + + + PO #2 Confirmed + creates reception #2 + + + + Reception #2 GOES THROUGH! + + constraint passes (nothing to check) + MO #2 created — TWO MOs! + + !! + + + + AVAILABLE + + SHOULD BE BLOCKED — but isn't (flowable_production_id = NULL) + TANK LOCATION STATE + + + + Bug #2: blocking depends on production.action_assign() success inside picking._action_done() + Blocking is set inside stock.move.write() only when action_assign changes the raw move state. + If reservation fails → state doesn't change → write() not triggered → flowable_production_id stays NULL. + The constraint has nothing to enforce — flowable_blocked is False. + + + + Example: Tank-A — PO/001 (2025-01-15) + PO/002 (2025-03-01) — MO 001 + MO 002 + diff --git a/stock_location_flowable/doc/diagrams/04_mo_completion.svg b/stock_location_flowable/doc/diagrams/04_mo_completion.svg new file mode 100644 index 000000000..718c0970c --- /dev/null +++ b/stock_location_flowable/doc/diagrams/04_mo_completion.svg @@ -0,0 +1,68 @@ + + + + + + + + + Scenario 4: MO Completion — Location Unblocked + A PO was received, MO was created, tank is blocked. When the MO completes, the tank is unblocked for the next reception. + + + + time + + + + + + + + + + + + + + + + PO Received + MO created, tank BLOCKED + + + + MO Processing + mixing / consuming stock + + + + MO Marked as Done + raw move state → "done" + stock.move.write() clears + flowable_production_id = NULL + + + + Ready for Next PO + tank available again + + + + AVAIL + + + BLOCKED (MO active) + + + AVAILABLE + + TANK LOCATION STATE + + + + Mechanism: stock.move.write() override detects raw move going to "done" or "cancel" + → clears production.location_dest_id.flowable_production_id (the flowable location) + diff --git a/stock_location_flowable/doc/diagrams/05_mo_cancellation.svg b/stock_location_flowable/doc/diagrams/05_mo_cancellation.svg new file mode 100644 index 000000000..cae08e140 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/05_mo_cancellation.svg @@ -0,0 +1,58 @@ + + + + + + + + + Scenario 5: MO Cancellation — Location Unblocked + Same as Scenario 4, but the MO is cancelled instead of completed. The unblocking mechanism is identical. + + + + time + + + + + + + + + + + + + + PO Received + MO created, tank BLOCKED + + + + MO Cancelled + raw move state → "cancel" → unblocked + + + + Ready for Next PO + tank available again + + + + AVAIL + + + BLOCKED (MO active) + + + AVAILABLE + + TANK LOCATION STATE + + + + Same mechanism as Scenario 4: stock.move.write() detects raw move → "cancel" → clears flowable_production_id + diff --git a/stock_location_flowable/doc/diagrams/06_internal_transfer.svg b/stock_location_flowable/doc/diagrams/06_internal_transfer.svg new file mode 100644 index 000000000..fd9c17472 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/06_internal_transfer.svg @@ -0,0 +1,59 @@ + + + + + + + + + Scenario 6 — Bug #1: Internal Transfer to Blocked Location — Constraint Bypassed + Location IS blocked by an MO. An internal transfer moves stock to the same tank. + The constraint SKIPS the transfer (non-MO move). Stock arrives at the blocked tank. + + + + + + + + + + + + + + + PO Received + MO created, location BLOCKED + + + + MO Processing + tank still blocked + + + + Internal Transfer GOES THROUGH! + + constraint skips non-MO moves + + !! + + + + AVAIL + + BLOCKED (MO active) — but constraint doesn't enforce it for non-MO moves + TANK LOCATION STATE + + + + Bug #1: same as Scenario 2 — constraint checks move's production, not location's blocking state + Internal transfer has no production → "if production and ..." is False → constraint entirely skipped. + + + + Fixed by PR #847: github.com/nuobit/odoo-addons/pull/847 + diff --git a/stock_location_flowable/doc/diagrams/07_proposed_fix.svg b/stock_location_flowable/doc/diagrams/07_proposed_fix.svg new file mode 100644 index 000000000..826634de4 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/07_proposed_fix.svg @@ -0,0 +1,80 @@ + + + + + + + + + Scenario 7 — Fix: Post-check in action_assign Ensures Blocking + action_assign() override in mrp.production checks full reservation after super(). + If not fully assigned: UserError with details of who holds reservations. Transaction rolls back. + + + + + + + + + + + + + + + + + + + PO #1 Confirmed + creates reception #1 + + + + Reception #1 Validated + + + FIX + action_assign OK → BLOCKED + + + time passes + + + + PO #2 Confirmed + creates reception #2 + + + + Reception #2 REJECTED + location blocked by MO #1 + X + + + + MO #1 Done + unblocked + + + + AVAILABLE + + BLOCKED — action_assign succeeds, post-check passes + + TANK LOCATION STATE + + + + Fix: action_assign() override with _check_flowable_reservation() post-check + After super().action_assign(): if any raw move is not "assigned" → UserError with conflicting reservations. + UserError inside _action_done() rolls back the entire transaction — no stock moved, no MO persisted. + Compare with Scenario 3: without this fix, action_assign fails silently and location is never blocked. + + + + EAFP: try the operation, check the result, roll back if it failed + diff --git a/stock_location_flowable/doc/diagrams/08_reception_blocked_by_reservations.svg b/stock_location_flowable/doc/diagrams/08_reception_blocked_by_reservations.svg new file mode 100644 index 000000000..03ce5647f --- /dev/null +++ b/stock_location_flowable/doc/diagrams/08_reception_blocked_by_reservations.svg @@ -0,0 +1,87 @@ + + + + + + + + + Scenario 8 — Reception Rolled Back: action_assign Fails Due to Reservations + Tank has stock. Internal transfers reserved part of it. PO reception validates, MO created. + action_assign() can't fully reserve → post-check raises UserError → entire transaction rolls back. + + + + + + + + + + + + + + + + + + + Previous MO Done + tank has stock, unblocked + + + + Internal Transfer Created + + reserves 6,949 kg from tank + WH/INT/00001 + + + + Another Transfer Created + + reserves 3,500 kg from tank + WH/INT/00002 + + + + New PO Reception + validates, MO created + + + + ROLLED BACK + + action_assign partial → + - 6,949 kg (INT/00001) + - 3,500 kg (INT/00002) + X + + + + AVAILABLE (not blocked) — has 10,449 kg reserved by outgoing transfers + TANK LOCATION STATE (unchanged — transaction rolled back) + + + + Tank quant state at time of action_assign + + + 22,000 kg available + + + 6,949 kg (INT/00001) + + + 3,500 kg (INT/00002) + + quantity_to_prod = 32,449 — but action_assign can only reserve 22,000 (available) + Post-check detects partial reservation → UserError → entire _action_done() rolls back + + + + User must unreserve INT/00001 and INT/00002 first — see Scenario 9 + diff --git a/stock_location_flowable/doc/diagrams/09_unreserve_and_retry.svg b/stock_location_flowable/doc/diagrams/09_unreserve_and_retry.svg new file mode 100644 index 000000000..12c3833b0 --- /dev/null +++ b/stock_location_flowable/doc/diagrams/09_unreserve_and_retry.svg @@ -0,0 +1,83 @@ + + + + + + + + + Scenario 9 — User Unreserves Outgoing Operations, Retries Reception + After Scenario 8: user goes to the listed operations, unreserves them, and retries the reception. + All quants now available. action_assign() succeeds, post-check passes, location blocked. + + + + + + + + + + + + + + + + + + + Reception ROLLED BACK + partial reservation detected + + + + Unreserve INT/00001 + user unreserves manually + + + + Unreserve INT/00002 + user unreserves manually + + + + Retry Reception + + action_assign OK + post-check passes, BLOCKED + + + + MO Processing + fully reserved + + + + HAS RESERVATIONS — action_assign would fail + + + + ALL AVAILABLE + + + + BLOCKED (MO) + + TANK LOCATION STATE + + + + User workflow after the error + + 1. Read the error message — it lists each operation and reserved quantity + 2. Go to each listed picking/operation → click "Unreserve" to release the quants + 3. Return to the reception picking → click "Validate" again + 4. action_assign() succeeds → post-check passes → location BLOCKED + + + + Old lot reservations become obsolete after the merge — the new lot replaces them all + diff --git a/stock_location_flowable/doc/flowable_location_blocking_flow.md b/stock_location_flowable/doc/flowable_location_blocking_flow.md new file mode 100644 index 000000000..5d3b683b5 --- /dev/null +++ b/stock_location_flowable/doc/flowable_location_blocking_flow.md @@ -0,0 +1,287 @@ +# Flowable Location Blocking Mechanism + +How the blocking mechanism works for flowable locations (tanks): lifecycle, concepts, +and scenarios. + +--- + +## 1. Key Concepts + +### 1.1 What is a flowable location? + +A physical tank (e.g. Tank-A, Tank-B). Identified by `flowable_storage = True` on +`stock.location`. It holds liquid/bulk material tracked by lot. + +### 1.2 Tank lifecycle + +A tank goes through a repeating cycle: + +**Available → Reception fills it → Blocked (MO merges) → MO done → Available** + +The tank is "occupied" the moment new material is poured in. While the MO processes the +merge, nobody else should deliver to that tank. + +![Tank Lifecycle](diagrams/00_tank_lifecycle.svg) + +### 1.3 Blocking vs reservation + +These are two independent concepts: + +- **Blocking** (`flowable_production_id` on `stock.location`): a physical constraint — + "this tank is in use, nobody should pour more material in." It's about physical + occupation. Binary: either blocked or not. + +- **Reservation** (`reserved_quantity` on `stock.quant`): an Odoo inventory mechanism — + "these quants are spoken for by a specific stock move." Multiple operations can + partially reserve quants at the same location simultaneously. + +A tank can be **available (not blocked) but have reserved quants** — this is the normal +case when outgoing operations (sales, internal transfers) have reserved material for +delivery. + +### 1.4 Partial reservation on a tank + +Whether partial reservation makes sense depends on the direction: + +- **Outgoing** (sales, internal transfers): **valid**. You can sell 3,500 kg from a + 35,000 kg tank. The sale reserves those 3,500 kg, the rest remains available for other + sales. + +- **Incoming** (receptions/merges): **invalid**. When new material arrives and a merge + MO is created, the MO must process the **entire** tank content (old + new). If some + quants are reserved by outgoing operations, the MO cannot reserve the full amount. + +### 1.5 Why lot reservations become obsolete after a merge + +This is a critical concept. Consider a tank with lot `LOT-001` (50,000 kg): + +1. Sales SO/123 and SO/456 each reserve 5,000 kg of lot `LOT-001` +2. A new PO reception arrives — merge MO is created +3. The MO consumes ALL quants (including `LOT-001`) and produces a **new lot** `LOT-002` + (55,000 kg = old 50,000 + new 5,000) +4. Lot `LOT-001` still exists in the database but has **zero stock** +5. The reservations by SO/123 and SO/456 for lot `LOT-001` are now **unfulfillable** — + the lot has no stock to deliver + +The reservations were already broken the moment the merge happened. This is why the +system forces the user to deal with existing reservations **before** the merge: those +reservations will become invalid anyway, so the user must consciously decide what to do +(unreserve, cancel, or reassign). + +### 1.6 How blocking fields work + +On `stock.location`: + +- **`flowable_production_id`** (Many2one → `mrp.production`): the MO that has blocked + this location. Set by `stock.move.write()` when `action_assign()` transitions a raw + move to `assigned`/`partially_available`. Cleared when the raw move goes to `done` or + `cancel`. + +- **`flowable_blocked`** (Boolean, computed, not stored): + `bool(flowable_storage and flowable_production_id)`. Convenience field — the real data + is `flowable_production_id`. + +The `not flowable_blocked` guard in `stock.move.write()` prevents a new MO from +overwriting an existing `flowable_production_id`. + +### 1.7 The reservation post-check in action_assign + +`action_assign()` is overridden in `mrp.production`. After `super()`, for flowable +operations it verifies all raw moves are in `assigned` state. If not, it searches for +who holds reservations at the source location and raises `UserError` with details. Since +this runs inside `_action_done()`, the `UserError` rolls back the entire transaction — +no stock moved, no MO persisted. + +This is a post-check (EAFP pattern): try the operation, verify the result, roll back if +it failed. Advantages: + +- **No concurrency window**: checks the actual result, not a prediction +- **Transaction safe**: `UserError` inside `_action_done` rolls back everything +- **Universal**: any caller of `action_assign()` on a flowable MO gets the check + +--- + +## 2. Scenarios + +### Scenario 1: Reception to Empty Tank — Happy Path + +Tank Tank-A is available and empty. A PO reception validates. + +1. `_action_done()` runs — stock moves to the tank +2. Merge MO is created with `quantity_to_prod = sum(all quants)` +3. `action_assign()` reserves all quants — succeeds (nothing else reserved) +4. Post-check passes (all raw moves in `assigned` state) +5. `stock.move.write()` sets `flowable_production_id` → **location BLOCKED** + +![First Reception](diagrams/01_first_reception.svg) + +### Scenario 2: Reception with No Outgoing Reservations — Happy Path + +Tank Tank-B has 32,000 kg from a previous merge. No outgoing operations have reserved +anything. A new PO reception validates. + +Same flow as Scenario 1: `action_assign()` reserves all 37,000 kg (old + new), +post-check passes, location blocked. This is the normal case when there are no pending +deliveries from the tank. + +![Fix — Post-check](diagrams/07_proposed_fix.svg) + +### Scenario 3: Reception with Sale/Transfer Reservations — Error and Rollback + +Tank Tank-B has 32,449 kg of lot `LOT-001`. Two outgoing operations have reserved part +of it: + +- Sale SO/001 → delivery WH/OUT/00001: reserved 6,949 kg of `LOT-001` +- Internal transfer WH/INT/00002: reserved 3,500 kg of `LOT-001` + +Available: 22,000 kg. A new PO reception validates: + +1. `_action_done()` runs — stock moves to the tank (now 37,449 kg total) +2. Merge MO created with `quantity_to_prod = 37,449` (sum of ALL quants) +3. `action_assign()` tries to reserve 37,449 but only 27,000 available +4. Raw move stays `confirmed` (not `assigned`) +5. **Post-check detects partial reservation** → `UserError`: + + ``` + Cannot fully reserve flowable location 'Tank-B' because there + are other reserved quantities. All stock must be available before merging. + + The following operations have reservations that must be unreserved first: + + - Product X: 6,949.17 kg — WH/OUT/00001 (Delivery Orders) + - Product X: 3,500.00 kg — WH/INT/00002 (Internal Transfers) + ``` + +6. **Entire transaction rolls back**: no stock moved, no MO created + +The error tells the user exactly which operations to fix. The user knows these +reservations are for lot `LOT-001`, which will cease to exist after the merge anyway +(see [1.5](#15-why-lot-reservations-become-obsolete-after-a-merge)). + +![Reception Blocked by Reservations](diagrams/08_reception_blocked_by_reservations.svg) + +### Scenario 4: User Unreserves and Retries — Success + +After Scenario 3, the user: + +1. Reads the error — sees the delivery and internal transfer holding reservations +2. Goes to WH/OUT/00001 → clicks "Unreserve" (releases 6,949 kg) +3. Goes to WH/INT/00002 → clicks "Unreserve" (releases 3,500 kg) +4. Returns to the PO reception → clicks "Validate" again +5. `action_assign()` succeeds — all quants available → **location BLOCKED** + +After the merge completes, lot `LOT-001` has zero stock and a new lot exists. The user +can then re-reserve the deliveries with the new lot if needed. + +![Unreserve and Retry](diagrams/09_unreserve_and_retry.svg) + +### Scenario 5: Second Reception to Blocked Location — Correctly Rejected + +Location is blocked by an active MO. A second PO reception tries to validate to the same +tank. The constraint `_check_flowable_location_blocked` on `stock.move.line` detects +`flowable_production_id` is set and the current move doesn't belong to that MO → +`ValidationError`. Reception rejected. + +This also applies to internal transfers — any move to a blocked location is rejected +regardless of origin. + +![Second Reception Blocked](diagrams/02_second_reception_blocked.svg) +![Internal Transfer Blocked](diagrams/06_internal_transfer.svg) + +### Scenario 6: MO Completion — Unblocking + +MO finishes processing. The raw move goes to `done`. `stock.move.write()` detects the +state change and clears `flowable_production_id`. Location is available again for the +next reception. + +![MO Completion](diagrams/04_mo_completion.svg) + +### Scenario 7: MO Cancellation — Unblocking + +Same mechanism as completion. `stock.move.write()` clears `flowable_production_id` when +the raw move goes to `cancel`. + +![MO Cancellation](diagrams/05_mo_cancellation.svg) + +--- + +## 3. Historical Reference: Example Case: Duplicate MOs on Same Tank + +Two PO receptions were validated to the same tank, creating duplicate MOs: + +| MO | Picking | Origin | Created | +| ------ | --------- | ------ | ---------- | +| MO 001 | WH/IN/001 | PO/001 | 2025-01-15 | +| MO 002 | WH/IN/002 | PO/002 | 2025-03-01 | + +MO 001's `action_assign()` failed (0 available quants — all reserved by other +operations) → location never blocked → 45 days later PO/002 received into the same tank +unimpeded. + +Two issues contributed: + +1. The blocked-location constraint in `stock_move_line.py` skipped non-MO moves, so even + blocked locations weren't protected from PO receptions +2. `action_assign()` failed silently when quants were partially reserved, leaving the + location unblocked + +Both are now fixed: + +1. The constraint checks `location.flowable_production_id` directly — any move to a + blocked location is rejected +2. The `action_assign()` override detects failed reservations and raises `UserError` + with details, rolling back the transaction + +--- + +## 4. Future Improvements + +- **Auto-unreserve on reception**: Instead of requiring manual unreservation, + automatically unreserve outgoing operations when receiving at a flowable location. + Deferred to observe how the manual approach works in production first — + auto-unreserving could disrupt planned deliveries without the user being aware. + +--- + +## 5. Validation Queries + +```sql +-- Check current state of a flowable location: +SELECT id, name, flowable_production_id, flowable_storage +FROM stock_location WHERE id = ; + +-- Check active MOs on a flowable location: +SELECT mp.id, mp.name, mp.state, sp.name as picking, mp.create_date +FROM mrp_production mp +LEFT JOIN stock_picking sp ON sp.id = mp.picking_id +WHERE mp.location_src_id = + AND mp.state NOT IN ('done', 'cancel'); + +-- Check reserved quantities at all flowable locations: +SELECT sl.name as location, + round(COALESCE(SUM(sq.quantity), 0)::numeric, 2) as total_qty, + round(COALESCE(SUM(sq.reserved_quantity), 0)::numeric, 2) as reserved, + round((COALESCE(SUM(sq.quantity), 0) + - COALESCE(SUM(sq.reserved_quantity), 0))::numeric, 2) as available +FROM stock_location sl +LEFT JOIN stock_quant sq ON sq.location_id = sl.id AND sq.quantity > 0 +WHERE sl.flowable_storage = true +GROUP BY sl.id, sl.name +HAVING COALESCE(SUM(sq.reserved_quantity), 0) > 0 +ORDER BY sl.name; + +-- Check who is reserving at a specific flowable location: +SELECT sml.product_uom_qty as reserved, + pp.default_code as product, + sm.reference, + sp.name as picking, + mp.name as mo +FROM stock_move_line sml +JOIN stock_move sm ON sm.id = sml.move_id +JOIN product_product pp ON pp.id = sml.product_id +LEFT JOIN stock_picking sp ON sp.id = sm.picking_id +LEFT JOIN mrp_production mp ON mp.id = sm.raw_material_production_id +WHERE sml.location_id = + AND sml.product_uom_qty > 0 + AND sm.state NOT IN ('done', 'cancel', 'draft'); +``` diff --git a/stock_location_flowable/i18n/ca.po b/stock_location_flowable/i18n/ca.po new file mode 100644 index 000000000..f6540c683 --- /dev/null +++ b/stock_location_flowable/i18n/ca.po @@ -0,0 +1,495 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_flowable +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-11-27 11:13+0000\n" +"PO-Revision-Date: 2023-11-27 11:13+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "All allowed products must be tracked by lot" +msgstr "Tots els productes permesos han de ser rastrejats per lot" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_allowed_product_ids +msgid "Allowed Products" +msgstr "Productes Permesos" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Blocked" +msgstr "Bloquejat" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot fully reserve the mixing order at flowable location '%s'. Raw " +"materials are in state '%s'." +msgstr "" +"No es pot reservar completament l'ordre de barreja a la ubicació fluida " +"'%s'. Les matèries primeres estan en estat '%s'." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot merge at flowable location '%s' because there are reserved " +"quantities. After the merge, the current lot(s) will have 0 stock and these " +"reservations will become invalid.\n" +"\n" +"The following operations must be unreserved or completed first:\n" +"\n" +"%s" +msgstr "" +"No es pot barrejar a la ubicació fluida '%s' perquè hi ha quantitats " +"reservades. Després de la barreja, els lots actuals tindran 0 estoc i " +"aquestes reserves seran invàlides.\n" +"\n" +"Les següents operacions s'han d'alliberar o completar primer:\n" +"\n" +"%s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"After completing the mixing order '%s' at flowable location '%s', lot '%s' " +"still has %s %s of stock. Expected 0 after merging all raw materials into " +"lot '%s'." +msgstr "" +"Després de completar l'ordre de barreja '%s' a la ubicació fluida '%s', el " +"lot '%s' encara té %s %s d'estoc. S'esperava 0 després de barrejar totes les " +"matèries primeres al lot '%s'." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Capacity" +msgstr "Capacitat" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than 0" +msgstr "La capacitat ha de ser superior a 0" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than capacity occupied %s" +msgstr "La capacitat ha de ser superior a la capacitat ocupada %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Initial reception at flowable location '%s' for product '%s': expected 1 " +"positive quant (empty tank) but found %d." +msgstr "" +"Recepció inicial a la ubicació fluida '%s' per al producte '%s': s'esperava " +"1 quant positiu (tanc buit) però se n'han trobat %d." + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Create Lots" +msgstr "Crear Lots" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__display_name +msgid "Display Name" +msgstr "Nom mostrat" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Enable" +msgstr "Habilitar" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Flowable" +msgstr "Fluid" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity_occupied +msgid "Flowable Capacity Occupied" +msgstr "Capacitat Fluida Ocupada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Flowable Location Warning" +msgstr "Avís d'Ubicació Fluida" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__flowable_operation +msgid "Flowable Operation" +msgstr "Operació Fluida" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_percentage_occupied +msgid "Flowable Percentage Occupied" +msgstr "Percentatge Fluid Ocupat" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_production_id +msgid "Flowable Production" +msgstr "Producció de Fluid" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_storage +msgid "Flowable Storage" +msgstr "Emmagatzematge de Fluid" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__id +msgid "ID" +msgstr "ID" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_location +msgid "Inventory Locations" +msgstr "Ubicacions d'inventari" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_blocked_popover +msgid "JSON data for the popover widget" +msgstr "Dades JSON per al widget emergent" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking____last_update +msgid "Last Modified on" +msgstr "Última modificació el" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Location capacity is full" +msgstr "La capacitat de la ubicació està plena" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Manufacturing Order" +msgstr "Ordre de producció" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__flowable_production_ids +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.view_picking_form +#, python-format +msgid "Manufacturing Orders" +msgstr "Ordres de producció" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "More than one flowable manufacturing picking type in warehouse %s" +msgstr "" +"Més d'un tipus d'operació de fabricació fluida al magatzem %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected 2 " +"positive quants but found %d." +msgstr "" +"Recepció de barreja a la ubicació fluida '%s' per al producte '%s': " +"s'esperaven 2 quants positius però se n'han trobat %d." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected " +"exactly 1 quant for the received lot '%s' but found %d." +msgstr "" +"Recepció de barreja a la ubicació fluida '%s' per al producte '%s': " +"s'esperava exactament 1 quant per al lot rebut '%s' però se n'han trobat %d." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found flowable manufacturing picking type in warehouse %s" +msgstr "" +"No s'ha trobat el tipus d'operació de fabricació fluida al magatzem %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found sequence in flowable manufacturing picking type %s" +msgstr "" +"No s'ha trobat seqüència al tipus d'operació de fabricació fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only manufacturing picking types can be flowable." +msgstr "Només els tipus d'operació de fabricació poden ser fluids." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only one picking type can be flowable in a warehouse %s." +msgstr "Només un tipus d'operació pot ser fluid al magatzem %s." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__picking_id +msgid "Picking" +msgstr "Albarà" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking_type +msgid "Picking Type" +msgstr "Tipus d'albarà" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s must be tracked by lot" +msgstr "El producte %s ha de ser rastrejat per lot" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s not allowed in flowable location %s" +msgstr "Producte %s no permès a la ubicació fluida %s" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "Moviments de Producte (Stock Move Line)" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_mrp_production +msgid "Production Order" +msgstr "Ordre de producció" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_return_picking +msgid "Return Picking" +msgstr "Albarà de devolució" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_sequence_id +msgid "Sequence" +msgstr "Seqüència" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move +msgid "Stock Move" +msgstr "Moviment d'inventari" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"The allowed products %s cannot have different Unit of Measure than flowable " +"location %s" +msgstr "" +"Els productes permesos %s no poden tenir una unitat de mesura diferent a la " +"ubicació fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_move_line.py:0 +#, python-format +msgid "" +"The location %s is blocked. Probably you need to review the pending " +"manufacturing orders related to this location" +msgstr "" +"La ubicació %s està bloquejada. Probablement necessites revisar les ordres " +"de producció pendents relacionades amb aquesta ubicació" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "The location is blocked" +msgstr "La ubicació està bloquejada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"The product %s is measured in %s. You can only assign products that have the " +"allowed unit of measure" +msgstr "" +"El producte %s es mesura en %s. Només pots assignar productes que tinguin la " +"unitat de mesura permesa" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"This location is blocked because it has a manufacturing order assigned." +msgstr "" +"Aquesta ubicació està bloquejada perquè té una ordre de producció assignada." + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking +msgid "Transfer" +msgstr "Albarà" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_uom_id +msgid "Unit of Measure" +msgstr "Unitat de Mesura" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "Unknown origin (move %s)" +msgstr "Origen desconegut (moviment %s)" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Cannot receive multiple product/lot combinations (%s) at flowable location " +"'%s' in the same receipt. Each combination generates a separate mixing order " +"and the location is blocked after the first one. Create a backorder to " +"receive them in separate steps." +msgstr "" +"No es poden rebre múltiples combinacions de producte/lot (%s) a la ubicació " +"de fluids '%s' en el mateix albarà. Cada combinació genera una ordre de " +"mescla separada i la ubicació queda bloquejada després de la primera. Creeu " +"un albarà parcial per rebre'ls en passos separats." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot cancel a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No es pot cancel·lar una producció que tingui un albarà associat. La barreja " +"està en curs." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot convert this location into a flowable location because there are " +"products with different units of measure." +msgstr "" +"No pots convertir aquesta ubicació en una ubicació fluida perquè hi ha " +"productes amb diferents unitats de mesura." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot convert this location into a flowable location because there are " +"unmixed products." +msgstr "" +"No pots convertir aquesta ubicació en una ubicació fluida perquè hi ha " +"productes sense barrejar." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You cannot disable flowable storage from a blocked location." +msgstr "" +"No pots deshabilitar l'emmagatzematge fluid d'una ubicació bloquejada." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_quant.py:0 +#, python-format +msgid "You cannot have more than one lot in the same location." +msgstr "No es pot tenir més d'un lot a la mateixa ubicació." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot modify a mix production with a picking associated. The mixing is " +"in progress." +msgstr "" +"No es pot modificar una producció de barreja que tingui un albarà associat. " +"La barreja està en curs." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_move.py:0 +#, python-format +msgid "" +"You cannot modify a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No es pot modificar una producció que tingui un albarà associat. La barreja " +"està en curs." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot remove a product that is currently stored in this location." +msgstr "" +"No pots eliminar un producte que estigui emmagatzemat en aquesta ubicació." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_return_picking.py:0 +#, python-format +msgid "" +"You cannot return the following products because they come from a flowable " +"location: %s" +msgstr "" +"No pots retornar els següents productes perquè provenen d'una ubicació " +"fluida: %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You have stock movements with different unit of measure" +msgstr "Tens moviments d'estoc amb diferent unitat de mesura" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a sequence" +msgstr "Has de seleccionar una seqüència" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a unit of measure" +msgstr "Has de seleccionar una unitat de mesura" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select products" +msgstr "Has de seleccionar productes" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "no lot" +msgstr "sense lot" diff --git a/stock_location_flowable/i18n/es.po b/stock_location_flowable/i18n/es.po new file mode 100644 index 000000000..b2cb64a76 --- /dev/null +++ b/stock_location_flowable/i18n/es.po @@ -0,0 +1,515 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_location_flowable +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-11-27 11:13+0000\n" +"PO-Revision-Date: 2023-11-27 11:13+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "All allowed products must be tracked by lot" +msgstr "Todos los productos permitidos deben ser rastreados por lote." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_allowed_product_ids +msgid "Allowed Products" +msgstr "Productos Permitidos" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#: model:ir.actions.act_window,name:stock_location_flowable.action_picking_tree_blocked +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_picking_type_kanban +#, python-format +msgid "Blocked" +msgstr "Bloqueado" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot fully reserve the mixing order at flowable location '%s'. Raw " +"materials are in state '%s'." +msgstr "" +"No se puede reservar completamente la orden de mezcla en la ubicación fluida " +"'%s'. Las materias primas están en estado '%s'." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"Cannot merge at flowable location '%s' because there are reserved " +"quantities. After the merge, the current lot(s) will have 0 stock and these " +"reservations will become invalid.\n" +"\n" +"The following operations must be unreserved or completed first:\n" +"\n" +"%s" +msgstr "" +"No se puede mezclar en la ubicación fluida '%s' porque hay cantidades " +"reservadas. Después de la mezcla, los lotes actuales tendrán 0 stock y " +"estas reservas serán inválidas.\n" +"\n" +"Las siguientes operaciones deben ser liberadas o completadas primero:\n" +"\n" +"%s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"After completing the mixing order '%s' at flowable location '%s', lot '%s' " +"still has %s %s of stock. Expected 0 after merging all raw materials into " +"lot '%s'." +msgstr "" +"Después de completar la orden de mezcla '%s' en la ubicación fluida '%s', el " +"lote '%s' todavía tiene %s %s de stock. Se esperaba 0 después de mezclar " +"todas las materias primas en el lote '%s'." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Capacity" +msgstr "Capacidad" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than 0" +msgstr "La capacidad debe ser mayor que 0." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Capacity must be greater than capacity occupied %s" +msgstr "La capacidad debe ser mayor que la capacidad ocupada %s" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__count_picking_blocked +msgid "Count Picking Blocked" +msgstr "Conteo de Albarán Bloqueado" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Initial reception at flowable location '%s' for product '%s': expected 1 " +"positive quant (empty tank) but found %d." +msgstr "" +"Recepción inicial en la ubicación fluida '%s' para el producto '%s': se " +"esperaba 1 quant positivo (tanque vacío) pero se encontraron %d." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__create_lots +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Create Lots" +msgstr "Crear Lotes" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__display_name +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Enable" +msgstr "Habilitar" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Flowable" +msgstr "Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_capacity_occupied +msgid "Flowable Capacity Occupied" +msgstr "Capacidad Fluida Ocupada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Flowable Location Warning" +msgstr "Advertencia de Ubicación Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__flowable_operation +msgid "Flowable Operation" +msgstr "Operación Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_percentage_occupied +msgid "Flowable Percentage Occupied" +msgstr "Porcentaje Fluido Ocupado" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_production_id +msgid "Flowable Production" +msgstr "Producción de Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_storage +msgid "Flowable Storage" +msgstr "Almacenamiento de Fluido" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type__id +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking__id +msgid "ID" +msgstr "ID" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_location +msgid "Inventory Locations" +msgstr "Ubicaciones de inventario" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_blocked_popover +msgid "JSON data for the popover widget" +msgstr "Datos JSON para el widget emergente" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking_type____last_update +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_return_picking____last_update +msgid "Last Modified on" +msgstr "Última modificación el" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "Location capacity is full" +msgstr "La capacidad de la ubicación está llena" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "Manufacturing Order" +msgstr "Orden de producción" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_picking__flowable_production_ids +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.view_picking_form +#, python-format +msgid "Manufacturing Orders" +msgstr "Ordenes de producción" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "More than one flowable manufacturing picking type in warehouse %s" +msgstr "" +"Más de un tipo de operación de fabricación fluida en el almacén %s" + +#. module: stock_location_flowable +#: model_terms:ir.actions.act_window,help:stock_location_flowable.action_picking_tree_blocked +msgid "No transfer found. Let's create one!" +msgstr "No se encontró ninguna transferencia. ¡Creemos uno!" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected 2 " +"positive quants but found %d." +msgstr "" +"Recepción de mezcla en la ubicación fluida '%s' para el producto '%s': se " +"esperaban 2 quants positivos pero se encontraron %d." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Mixing reception at flowable location '%s' for product '%s': expected " +"exactly 1 quant for the received lot '%s' but found %d." +msgstr "" +"Recepción de mezcla en la ubicación fluida '%s' para el producto '%s': se " +"esperaba exactamente 1 quant para el lote recibido '%s' pero se encontraron " +"%d." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found flowable manufacturing picking type in warehouse %s" +msgstr "" +"No se encontró el tipo de operación de fabricación fluida en el almacén %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Not found sequence in flowable manufacturing picking type %s" +msgstr "" +"No se encontró secuencia en el tipo de operación de fabricación fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only manufacturing picking types can be flowable." +msgstr "Solo los tipos de operación de fabricación pueden ser fluidos." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking_type.py:0 +#, python-format +msgid "Only one picking type can be flowable in a warehouse %s." +msgstr "Sólo un tipo de operación puede ser fluido en el almacén %s." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_mrp_production__picking_id +msgid "Picking" +msgstr "Albarán" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking_type +msgid "Picking Type" +msgstr "Tipo de albarán" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s must be tracked by lot" +msgstr "El producto %s debe ser rastreado por lote" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "Product %s not allowed in flowable location %s" +msgstr "Producto %s no permitido en ubicación fluida %s" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "Movimientos de Producto (Stock Move Line)" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_mrp_production +msgid "Production Order" +msgstr "Orden de producción" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_return_picking +msgid "Return Picking" +msgstr "Albarán de devolución" + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_sequence_id +msgid "Sequence" +msgstr "Secuencia" + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_move +msgid "Stock Move" +msgstr "Movimiento de inventario" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"The allowed products %s cannot have different Unit of Measure than flowable " +"location %s" +msgstr "" +"Los productos permitidos %s no pueden tener una unidad de medida diferente a " +"la ubicación fluida %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_move_line.py:0 +#, python-format +msgid "" +"The location %s is blocked. Probably you need to review the pending " +"manufacturing orders related to this location" +msgstr "" +"La ubicación %s está bloqueada. Probablemente necesites revisar las " +"órdenes de producción pendientes relacionadas con esta ubicación" + +#. module: stock_location_flowable +#: model_terms:ir.ui.view,arch_db:stock_location_flowable.stock_location_view_form +msgid "The location is blocked" +msgstr "La ubicación está bloqueada." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"The product %s is measured in %s. You can only assign products that have the " +"allowed unit of measure" +msgstr "" +"El producto %s se mide en %s. Sólo puedes asignar productos que tengan la " +"unidad de medida permitida" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"This location is blocked because it has a manufacturing order assigned." +msgstr "" +"Esta ubicación está bloqueada porque tiene una orden de producción asignada." + +#. module: stock_location_flowable +#: model:ir.model,name:stock_location_flowable.model_stock_picking +msgid "Transfer" +msgstr "Albarán" + +#. module: stock_location_flowable +#: model_terms:ir.actions.act_window,help:stock_location_flowable.action_picking_tree_blocked +msgid "Transfers allow you to move products from one location to another." +msgstr "Las transferencias le permiten mover productos de un lugar a otro." + +#. module: stock_location_flowable +#: model:ir.model.fields,field_description:stock_location_flowable.field_stock_location__flowable_uom_id +msgid "Unit of Measure" +msgstr "Unidad de Medida" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "Unknown origin (move %s)" +msgstr "Origen desconocido (movimiento %s)" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_picking.py:0 +#, python-format +msgid "" +"Cannot receive multiple product/lot combinations (%s) at flowable location " +"'%s' in the same receipt. Each combination generates a separate mixing order " +"and the location is blocked after the first one. Create a backorder to " +"receive them in separate steps." +msgstr "" +"No se pueden recibir múltiples combinaciones de producto/lote (%s) en la " +"ubicación de fluidos '%s' en el mismo albarán. Cada combinación genera una " +"orden de mezcla separada y la ubicación queda bloqueada después de la " +"primera. Cree un albarán parcial para recibirlos en pasos separados." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot cancel a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No se puede cancelar una producción que tenga un albarán asociado. La mezcla " +"está en curso." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot convert this location into a flowable location because there are " +"products with different units of measure." +msgstr "" +"No puedes convertir esta ubicación en una ubicación fluida porque hay " +"productos con diferentes unidades de medida." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot convert this location into a flowable location because there are " +"unmixed products." +msgstr "" +"No puedes convertir esta ubicación en una ubicación fluida porque hay " +"productos sin mezclar." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You cannot disable flowable storage from a blocked location." +msgstr "" +"No puedes deshabilitar el almacenamiento fluido de una ubicación bloqueada" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_quant.py:0 +#, python-format +msgid "You cannot have more than one lot in the same location." +msgstr "No se puede tener más de un lote en la misma ubicación." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "" +"You cannot modify a mix production with a picking associated. The mixing is " +"in progress." +msgstr "" +"No se puede modificar una producción de mezcla que tenga un albarán " +"asociado. La mezcla está en curso." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_move.py:0 +#, python-format +msgid "" +"You cannot modify a production with a picking associated. The mixing is in " +"progress." +msgstr "" +"No se puede modificar una producción que tenga un albarán asociado. La " +"mezcla está en curso." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "" +"You cannot remove a product that is currently stored in this location." +msgstr "" +"No puedes eliminar un producto que esté actualmente almacenado en esta " +"ubicación." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_return_picking.py:0 +#, python-format +msgid "" +"You cannot return the following products because they come from a flowable " +"location: %s" +msgstr "" +"No puedes devolver los siguientes productos porque provienen de una " +"ubicación fluida: %s" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You have stock movements with different unit of measure" +msgstr "Tienes movimientos de stock con diferente unidad de medida" + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a sequence" +msgstr "Debes seleccionar una secuencia." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select a unit of measure" +msgstr "Debes seleccionar una unidad de medida." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/stock_location.py:0 +#, python-format +msgid "You must select products" +msgstr "Debes seleccionar productos." + +#. module: stock_location_flowable +#: code:addons/stock_location_flowable/models/mrp_production.py:0 +#, python-format +msgid "no lot" +msgstr "sin lote" diff --git a/stock_location_flowable/models/__init__.py b/stock_location_flowable/models/__init__.py new file mode 100644 index 000000000..ea5324afe --- /dev/null +++ b/stock_location_flowable/models/__init__.py @@ -0,0 +1,8 @@ +from . import stock_location +from . import stock_picking +from . import mrp_production +from . import stock_return_picking +from . import stock_move_line +from . import stock_picking_type +from . import stock_move +from . import stock_quant diff --git a/stock_location_flowable/models/mrp_production.py b/stock_location_flowable/models/mrp_production.py new file mode 100644 index 000000000..b9e29e31e --- /dev/null +++ b/stock_location_flowable/models/mrp_production.py @@ -0,0 +1,173 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_is_zero + + +class MrpProduction(models.Model): + _inherit = "mrp.production" + + picking_id = fields.Many2one(comodel_name="stock.picking") + production_blocked = fields.Boolean(compute="_compute_production_blocked") + production_flowable = fields.Boolean(compute="_compute_production_flowable") + + def _compute_production_flowable(self): + for rec in self: + rec.production_flowable = rec.picking_type_id.flowable_operation + + def _compute_production_blocked(self): + for rec in self: + rec.production_blocked = bool( + self.env["stock.location"].search_count( + [("flowable_production_id", "=", rec.id)] + ) + ) + + @api.constrains("state") + def _check_flowable_blocked(self): + for rec in self: + if rec.state == "cancel" and rec.picking_id: + raise ValidationError( + _( + "You cannot cancel a production with a picking associated." + " The mixing is in progress." + ) + ) + + def write(self, vals): + for rec in self: + if ( + rec.picking_id + and rec.picking_type_id.flowable_operation + and rec.state == "to_close" + ): + raise ValidationError( + _( + "You cannot modify a mix production with a picking associated." + " The mixing is in progress." + ) + ) + return super().write(vals) + + def button_mark_done(self): + res = super().button_mark_done() + for rec in self: + if rec.picking_type_id.flowable_operation and rec.state == "done": + rec._check_flowable_post_production_quants() + return res + + def _check_flowable_post_production_quants(self): + """Defensive check — should not be necessary under normal operation + and might be removed in the future. After completing a mixing order, + all raw-material lots at the flowable location must have 0 stock — + only the producing lot should remain. Rounding residuals or manual + inventory adjustments could leave non-zero leftovers that silently + corrupt stock. Fail loudly so the issue is caught immediately.""" + self.ensure_one() + location = self.location_src_id + rounding = self.product_uom_id.rounding + quants = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product_id.id), + ("location_id", "=", location.id), + ("lot_id", "!=", self.lot_producing_id.id), + ("company_id", "=", self.company_id.id), + ] + ) + for quant in quants: + if not float_is_zero(quant.quantity, precision_rounding=rounding): + raise ValidationError( + _( + "After completing the mixing order '%(order)s' at" + " flowable location '%(location)s', lot '%(lot)s' still has" + " %(qty)s %(uom)s of stock. Expected 0 after merging" + " all raw materials into lot '%(producing_lot)s'." + ) + % { + "order": self.name, + "location": location.name, + "lot": quant.lot_id.name, + "qty": quant.quantity, + "uom": self.product_uom_id.name, + "producing_lot": self.lot_producing_id.name, + } + ) + + def action_assign(self): + res = super().action_assign() + for rec in self: + if rec.picking_type_id.flowable_operation and rec.state not in ( + "to_close", + "done", + "cancel", + ): + rec._check_flowable_reservation() + return res + + def _check_flowable_reservation(self): + self.ensure_one() + if all(m.state == "assigned" for m in self.move_raw_ids): + return + location = self.location_src_id + reserved_move_lines = self.env["stock.move.line"].search( + [ + ("location_id", "=", location.id), + ("quantity_product_uom", ">", 0), + ("state", "not in", ("done", "cancel", "draft")), + ("move_id.raw_material_production_id", "!=", self.id), + ] + ) + if reserved_move_lines: + details = [] + for ml in reserved_move_lines: + move = ml.move_id + if move.picking_id: + origin = ( + f"{move.picking_id.name}" + f" ({move.picking_id.picking_type_id.name})" + ) + elif move.raw_material_production_id: + origin = ( + f"{move.raw_material_production_id.name}" + f" ({move.raw_material_production_id.picking_type_id.name})" + ) + else: + origin = _("Unknown origin (move %s)", move.id) + details.append( + " - {}: {} {} (lot {}) - {}".format( + ml.product_id.display_name, + ml.quantity_product_uom, + ml.product_uom_id.name, + ml.lot_id.name or _("no lot"), + origin, + ) + ) + raise UserError( + _( + "Cannot merge at flowable location '%(location)s'" + " because there are reserved quantities." + " After the merge, the current lot(s) will" + " have 0 stock and these reservations will" + " become invalid.\n\n" + "The following operations must be unreserved" + " or completed first:\n\n%(details)s" + ) + % { + "location": location.name, + "details": "\n".join(details), + } + ) + raise UserError( + _( + "Cannot fully reserve the mixing order at" + " flowable location '%(location)s'. Raw materials are" + " in state '%(states)s'." + ) + % { + "location": location.name, + "states": ", ".join(self.move_raw_ids.mapped("state")), + } + ) diff --git a/stock_location_flowable/models/stock_location.py b/stock_location_flowable/models/stock_location.py new file mode 100644 index 000000000..f4725dc87 --- /dev/null +++ b/stock_location_flowable/models/stock_location.py @@ -0,0 +1,239 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools.safe_eval import json + + +class StockLocation(models.Model): + _inherit = "stock.location" + + flowable_storage = fields.Boolean() + flowable_blocked = fields.Boolean(compute="_compute_flowable_blocked") + flowable_blocked_popover = fields.Char( + string="JSON data for the popover widget", + compute="_compute_flowable_blocked_popover", + ) + flowable_capacity = fields.Float(string="Capacity") + flowable_uom_id = fields.Many2one(string="Unit of Measure", comodel_name="uom.uom") + flowable_sequence_id = fields.Many2one( + string="Sequence", comodel_name="ir.sequence", check_company=True + ) + flowable_allowed_product_ids = fields.Many2many( + string="Allowed Products", comodel_name="product.product" + ) + flowable_production_id = fields.Many2one( + comodel_name="mrp.production", + ) + flowable_capacity_occupied = fields.Float( + compute="_compute_flowable_capacity_occupied", store=True + ) + flowable_percentage_occupied = fields.Float( + compute="_compute_flowable_percentage_occupied" + ) + flowable_create_lots = fields.Boolean() + + def _compute_flowable_blocked_popover(self): + for rec in self: + rec.flowable_blocked_popover = json.dumps( + { + "title": _("Flowable Location Warning"), + "msg": _( + "This location is blocked because it has " + "a manufacturing order assigned." + ), + } + ) + + @api.depends("flowable_production_id") + def _compute_flowable_blocked(self): + for rec in self: + rec.flowable_blocked = bool( + rec.flowable_storage and rec.flowable_production_id + ) + + @api.depends("quant_ids.quantity") + def _compute_flowable_capacity_occupied(self): + for rec in self: + if rec.flowable_storage: + rec.flowable_capacity_occupied = sum(rec.quant_ids.mapped("quantity")) + + @api.depends("flowable_capacity_occupied") + def _compute_flowable_percentage_occupied(self): + for rec in self: + if rec.flowable_capacity <= 0: + rec.flowable_percentage_occupied = 0 + else: + rec.flowable_percentage_occupied = ( + rec.flowable_capacity_occupied / rec.flowable_capacity * 100 + ) + + def action_view_mrp_production(self): + self.ensure_one() + return { + "name": _("Manufacturing Orders"), + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "mrp.production", + "res_id": self.flowable_production_id.id, + } + + @api.constrains("flowable_uom_id") + def _check_flowable_uom_id(self): + for rec in self: + if rec.flowable_storage: + if rec.quant_ids.filtered( + lambda x, rec=rec: x.product_uom_id != rec.flowable_uom_id + and x.quantity > 0 + ): + raise ValidationError( + _("You have stock movements with different unit of measure") + ) + + @api.constrains("flowable_capacity_occupied") + def _check_flowable_capacity_occupied(self): + for rec in self: + if rec.usage != "view" and rec.flowable_storage: + if rec.flowable_capacity_occupied > rec.flowable_capacity: + raise ValidationError(_("Location capacity is full")) + + @api.constrains("flowable_storage") + def _check_production_linked_flowable_location(self): + for rec in self: + if not rec.flowable_storage and rec.flowable_production_id: + raise ValidationError( + _("You cannot disable flowable storage from a blocked location.") + ) + + @api.constrains( + "flowable_storage", + "flowable_uom_id", + "flowable_allowed_product_ids", + "flowable_capacity", + "flowable_create_lots", + "flowable_sequence_id", + ) + def _check_required_fields_flowable_storage(self): + for rec in self: + if rec.usage != "view" and rec.flowable_storage: + if rec.flowable_capacity <= 0: + raise ValidationError(_("Capacity must be greater than 0")) + if rec.flowable_capacity < rec.flowable_capacity_occupied: + raise ValidationError( + _("Capacity must be greater than capacity occupied %s") + % rec.flowable_capacity_occupied + ) + if not rec.flowable_uom_id: + raise ValidationError(_("You must select a unit of measure")) + if not rec.flowable_allowed_product_ids: + raise ValidationError(_("You must select products")) + if rec.flowable_create_lots and not rec.flowable_sequence_id: + raise ValidationError(_("You must select a sequence")) + + @api.constrains("flowable_allowed_product_ids", "flowable_uom_id") + def _check_sequence_products_flowable_capacity(self): + for rec in self: + if rec.usage != "view" and rec.flowable_storage: + if rec.flowable_allowed_product_ids.filtered( + lambda x: x.tracking != "lot" + ): + raise ValidationError( + _("All allowed products must be tracked by lot") + ) + for product in rec.flowable_allowed_product_ids: + if product.uom_id != rec.flowable_uom_id: + raise ValidationError( + _( + "The product %(product)s is measured in %(uom)s." + " You can only assign" + " products that have the allowed unit of measure" + ) + % { + "product": product.name, + "uom": product.uom_id.name, + } + ) + + @api.depends("name", "location_id.complete_name", "usage", "flowable_blocked") + def _compute_complete_name(self): + for rec in self: + if rec.flowable_storage and rec.flowable_blocked: + rec.complete_name = "{}/{} [{}]".format( + rec.location_id.complete_name, + rec.name, + _("Blocked"), + ) + else: + return super(StockLocation, rec)._compute_complete_name() + + def name_get(self): + res = [] + for rec in self: + name_l = [rec.name] + if rec.flowable_storage and rec.flowable_blocked: + name_l.append("[Blocked]") + res.append((rec.id, " ".join(name_l))) + return res + + def write(self, vals): + res = True + for rec in self: + old_allowed_products = self.env["product.product"] + if "flowable_allowed_product_ids" in vals and vals.get( + "flowable_storage", rec.flowable_storage + ): + old_allowed_products = rec.flowable_allowed_product_ids + if vals.get("flowable_storage"): + for product in rec.quant_ids.product_id: + product_quant = rec.quant_ids.filtered( + lambda x, product=product: x.quantity > 0 + and x.product_id == product + ) + if len(product_quant) > 1: + raise UserError( + _( + "You cannot convert this location into" + " a flowable location because there" + " are unmixed products." + ) + ) + if product.uom_id.id != vals.get( + "flowable_uom_id", rec.flowable_uom_id.id + ): + raise UserError( + _( + "You cannot convert this location into" + " a flowable location because there" + " are products with different units" + " of measure." + ) + ) + elif not vals.get("flowable_storage", True): + vals.update( + { + "flowable_sequence_id": False, + "flowable_allowed_product_ids": False, + "flowable_capacity": 0, + "flowable_uom_id": False, + } + ) + res &= super(StockLocation, rec).write(vals) + if rec.flowable_storage: + removed_product_ids = set(old_allowed_products.ids) - set( + rec.flowable_allowed_product_ids.ids + ) + for product_id in removed_product_ids: + if rec.quant_ids.filtered( + lambda x, pid=product_id: x.product_id.id == pid + and x.quantity > 0 + ): + raise UserError( + _( + "You cannot remove a product that is currently" + " stored in this location." + ) + ) + return res diff --git a/stock_location_flowable/models/stock_move.py b/stock_location_flowable/models/stock_move.py new file mode 100644 index 000000000..c381d1a3b --- /dev/null +++ b/stock_location_flowable/models/stock_move.py @@ -0,0 +1,36 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _trigger_assign(self): + if self.env.context.get("flowable_skip_trigger_assign"): + return + return super()._trigger_assign() + + def write(self, vals): + for rec in self: + production = rec.raw_material_production_id + if not production.picking_type_id.flowable_operation: + continue + new_state = vals.get("state") + if not new_state: + continue + # Block location when MO raw materials are fully reserved + if new_state == "assigned": + if ( + vals.get("move_line_ids", rec.move_line_ids) + and production.location_dest_id.flowable_storage + and not production.location_dest_id.flowable_blocked + ): + production.location_dest_id.flowable_production_id = production + # Unblock location when MO raw materials are done or cancelled + elif new_state in ("cancel", "done"): + if production.location_dest_id.flowable_production_id == production: + production.location_dest_id.flowable_production_id = False + return super().write(vals) diff --git a/stock_location_flowable/models/stock_move_line.py b/stock_location_flowable/models/stock_move_line.py new file mode 100644 index 000000000..cad4e1375 --- /dev/null +++ b/stock_location_flowable/models/stock_move_line.py @@ -0,0 +1,48 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + raw_production_blocked = fields.Boolean( + related="move_id.raw_material_production_id.production_blocked" + ) + + @api.constrains("state") + def _check_flowable_location_blocked(self): + for rec in self: + if rec.state not in ("draft", "cancel"): + locations_to_check = self.env["stock.location"] + if ( + rec.location_dest_id.flowable_storage + and rec.location_dest_id.flowable_blocked + ): + locations_to_check |= rec.location_dest_id + if ( + rec.location_id.flowable_storage + and rec.location_id.flowable_blocked + ): + locations_to_check |= rec.location_id + for location in locations_to_check: + production = ( + rec.move_id.raw_material_production_id + or rec.move_id.production_id + ) + if ( + location.flowable_production_id + and location.flowable_production_id != production + ): + raise ValidationError( + _( + "The location %(location)s is blocked." + " Probably you need to review the pending" + " manufacturing orders related" + " to this location" + ) + % {"location": location.name} + ) diff --git a/stock_location_flowable/models/stock_picking.py b/stock_location_flowable/models/stock_picking.py new file mode 100644 index 000000000..b24fe3d98 --- /dev/null +++ b/stock_location_flowable/models/stock_picking.py @@ -0,0 +1,297 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + flowable_production_ids = fields.One2many( + string="Manufacturing Orders", + comodel_name="mrp.production", + inverse_name="picking_id", + ) + + def action_view_mrp_production(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "mrp.mrp_production_action" + ) + form = self.env.ref("mrp.mrp_production_form_view") + if len(self.flowable_production_ids) == 1: + action["views"] = [(form.id, "form")] + action["res_id"] = self.flowable_production_ids[0].id + else: + action["domain"] = [("id", "in", self.flowable_production_ids.ids)] + action["context"] = {**self.env.context, "search_default_todo": False} + return action + + def _prepare_lot_values(self, product, location_dest, quantity): + self.ensure_one() + return { + "name": location_dest.flowable_sequence_id._next(), + "product_id": product.id, + "product_qty": quantity, + "product_uom_id": product.uom_id.id, + } + + def _prepare_production_move_line_values(self, move_line, product, location_dest): + self.ensure_one() + return { + "lot_id": move_line.lot_id.id, + "product_id": product.id, + "quantity": move_line.quantity, + "picked": True, + "product_uom_id": product.uom_id.id, + "location_id": location_dest.id, + "location_dest_id": product.with_company( + self.company_id + ).property_stock_production.id, + } + + def _prepare_production_move_values( + self, product, location_dest, quantity_to_prod, mrp_operation_type + ): + self.ensure_one() + return { + "name": product.name, + "product_id": product.id, + "picking_type_id": mrp_operation_type.id, + "location_id": location_dest.id, + "location_dest_id": location_dest.id, + "product_uom": product.uom_id.id, + "product_uom_qty": quantity_to_prod, + } + + def _prepare_production_values( + self, product, location_dest, quantity_to_prod, mrp_operation_type + ): + self.ensure_one() + return { + "product_id": product.id, + "product_qty": quantity_to_prod, + "product_uom_id": product.uom_id.id, + "picking_type_id": mrp_operation_type.id, + "location_src_id": location_dest.id, + "location_dest_id": location_dest.id, + "picking_id": self.id, + "move_raw_ids": [ + ( + 0, + 0, + self._prepare_production_move_values( + product, location_dest, quantity_to_prod, mrp_operation_type + ), + ) + ], + } + + def button_validate(self): + for rec in self: + if rec.move_line_ids_without_package.filtered( + lambda x: x.location_dest_id.flowable_storage + or x.location_id.flowable_storage + ): + rec.env.context = dict(rec.env.context) + rec.env.context["allow_duplicate"] = True + return super().button_validate() + + def _action_done(self): + # has_flowable = any( + # ml.location_dest_id.flowable_storage + # for rec in self + # for ml in rec.move_line_ids_without_package + # ) + # if has_flowable: + # self = self.with_context(flowable_skip_trigger_assign=True) + res = super()._action_done() + for rec in self: + flowable_lines = rec.move_line_ids_without_package.filtered( + lambda x: x.location_dest_id.flowable_storage + ) + if not flowable_lines: + continue + + # checks before creating manufacturing orders + mrp_operation_type = rec.env["stock.picking.type"].search( + [ + ("warehouse_id", "=", rec.picking_type_id.warehouse_id.id), + ("code", "=", "mrp_operation"), + ("flowable_operation", "=", True), + ] + ) + if not mrp_operation_type: + raise UserError( + _( + "Not found flowable manufacturing picking type" + " in warehouse %(warehouse)s" + ) + % {"warehouse": rec.picking_type_id.warehouse_id.name} + ) + elif len(mrp_operation_type) > 1: + raise UserError( + _( + "More than one flowable manufacturing picking type" + " in warehouse %(warehouse)s" + ) + % {"warehouse": rec.picking_type_id.warehouse_id.name} + ) + else: + if not mrp_operation_type.sequence_id: + raise UserError( + _( + "Not found sequence in flowable manufacturing" + " picking type %(picking_type)s" + ) + % {"picking_type": mrp_operation_type.display_name} + ) + + # Group move lines by (product, dest location, lot) and check for + # conflicts. The blocking mechanism would catch this too (the first + # MO blocks the location, the second hits the constraint), but + # that error references a phantom MO created in the same rolled-back + # transaction. Pre-checking here gives an actionable message. + lines = {} + for line in flowable_lines: + key = (line.product_id, line.location_dest_id, line.lot_id) + lines[key] = lines.get(key, 0) + line.quantity + existing = [k for k in lines if k[1] == line.location_dest_id] + if len(existing) > 1: + details = ", ".join( + f"{k[0].name} ({k[2].name})" if k[2] else k[0].name + for k in existing + ) + raise UserError( + _( + "Cannot receive multiple product/lot combinations" + " (%(details)s) at flowable location '%(location)s'" + " in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." + ) + % { + "details": details, + "location": line.location_dest_id.name, + } + ) + + # create manufacturing orders + for (product, location_dest, lot), quantity in lines.items(): + if product not in location_dest.flowable_allowed_product_ids: + raise UserError( + _( + "Product %(product)s not allowed" + " in flowable location %(location)s" + ) + % { + "product": product.name, + "location": location_dest.name, + } + ) + if product.uom_id != location_dest.flowable_uom_id: + raise UserError( + _( + "The allowed products %(product)s cannot have" + " different Unit of Measure than" + " flowable location %(location)s" + ) + % { + "product": product.name, + "location": location_dest.name, + } + ) + if product.tracking != "lot": + raise UserError( + _("Product %(product)s must be tracked by lot") + % {"product": product.name} + ) + component_quant = rec.env["stock.quant"].search( + [ + ("product_id", "=", product.id), + ("location_id", "=", location_dest.id), + ("quantity", ">", 0), + ("company_id", "=", rec.company_id.id), + ] + ) + rec._check_flowable_quant_count( + component_quant, location_dest, product, lot + ) + quantity_to_prod = sum(component_quant.mapped("quantity")) + production = rec.env["mrp.production"].create( + rec._prepare_production_values( + product, location_dest, quantity_to_prod, mrp_operation_type + ) + ) + production.action_confirm() + if location_dest.flowable_create_lots: + lot = rec.env["stock.lot"].create( + rec._prepare_lot_values(product, location_dest, quantity) + ) + production.lot_producing_id = lot + production.action_assign() + production.move_raw_ids.move_line_ids.unlink() + vals = [] + for move_line in component_quant: + vals.append( + ( + 0, + 0, + rec._prepare_production_move_line_values( + move_line, product, location_dest + ), + ) + ) + production.move_raw_ids.move_line_ids = vals + production.qty_producing = quantity_to_prod + return res + + def _check_flowable_quant_count(self, quants, location, product, lot): + is_initial = all(q.lot_id == lot for q in quants) + if is_initial: + if len(quants) != 1: + raise UserError( + _( + "Initial reception at flowable location '%(location)s'" + " for product '%(product)s': expected 1 positive quant" + " (empty tank) but found %(count)d." + ) + % { + "location": location.name, + "product": product.name, + "count": len(quants), + } + ) + else: + if len(quants) != 2: + raise UserError( + _( + "Mixing reception at flowable location '%(location)s'" + " for product '%(product)s': expected 2 positive quants" + " but found %(count)d." + ) + % { + "location": location.name, + "product": product.name, + "count": len(quants), + } + ) + received = quants.filtered(lambda q: q.lot_id == lot) + if len(received) != 1: + raise UserError( + _( + "Mixing reception at flowable location '%(location)s'" + " for product '%(product)s': expected exactly 1 quant" + " for the received lot '%(lot)s' but found %(count)d." + ) + % { + "location": location.name, + "product": product.name, + "lot": lot.name, + "count": len(received), + } + ) diff --git a/stock_location_flowable/models/stock_picking_type.py b/stock_location_flowable/models/stock_picking_type.py new file mode 100644 index 000000000..02a6f8019 --- /dev/null +++ b/stock_location_flowable/models/stock_picking_type.py @@ -0,0 +1,39 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PickingType(models.Model): + _inherit = "stock.picking.type" + + flowable_operation = fields.Boolean(copy=False) + + @api.constrains("flowable_operation", "code", "warehouse_id", "company_id") + def _check_flowable_operation(self): + for rec in self: + if rec.flowable_operation: + if rec.code != "mrp_operation": + raise ValidationError( + _("Only manufacturing picking types can be flowable."), + ) + if ( + rec.env["stock.picking.type"].search_count( + [ + ("flowable_operation", "=", True), + ("warehouse_id", "=", rec.warehouse_id.id), + ("code", "=", rec.code), + ("company_id", "=", rec.company_id.id), + ] + ) + > 1 + ): + raise ValidationError( + _( + "Only one picking type can be flowable " + "in a warehouse %(warehouse_name)s." + ) + % {"warehouse_name": rec.warehouse_id.name} + ) diff --git a/stock_location_flowable/models/stock_quant.py b/stock_location_flowable/models/stock_quant.py new file mode 100644 index 000000000..c325e5a1c --- /dev/null +++ b/stock_location_flowable/models/stock_quant.py @@ -0,0 +1,37 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, models +from odoo.exceptions import ValidationError +from odoo.tools import float_compare + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + @api.constrains("location_id", "quantity") + def _check_unique_lot(self): + for rec in self: + if not self.env.context.get("allow_duplicate"): + if rec.location_id.flowable_storage: + if ( + len( + rec.product_id.stock_quant_ids.filtered( + lambda x, rec=rec: float_compare( + x.quantity, + 0, + precision_rounding=rec.product_uom_id.rounding, + ) + > 0 + and x.location_id == rec.location_id + ) + ) + > 1 + ): + raise ValidationError( + _( + "You cannot have more than one" + " lot in the same location." + ) + ) diff --git a/stock_location_flowable/models/stock_return_picking.py b/stock_location_flowable/models/stock_return_picking.py new file mode 100644 index 000000000..45a302a9c --- /dev/null +++ b/stock_location_flowable/models/stock_return_picking.py @@ -0,0 +1,36 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, models +from odoo.exceptions import UserError + + +class ReturnPicking(models.TransientModel): + _inherit = "stock.return.picking" + + def _create_return(self): + self.ensure_one() + move_line = self.picking_id.move_line_ids_without_package.filtered( + lambda x: x.location_dest_id.flowable_storage + and x.product_id in self.product_return_moves.product_id + ) + if move_line: + detail_tpl = _("%(product)s (%(location)s)") + details = ", ".join( + detail_tpl + % { + "product": ml.product_id.name, + "location": ml.location_dest_id.name, + } + for ml in move_line + ) + raise UserError( + _( + "You cannot return the following products because" + " they come from a flowable location: %s" + ) + % details + ) + return super()._create_return() diff --git a/stock_location_flowable/pyproject.toml b/stock_location_flowable/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/stock_location_flowable/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/stock_location_flowable/readme/CONTRIBUTORS.md b/stock_location_flowable/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..b7d9bad9b --- /dev/null +++ b/stock_location_flowable/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +- [NuoBiT](https://www.nuobit.com): + - Frank Cespedes + - Deniz Gallo + - Bijaya Kumal + - Eric Antones diff --git a/stock_location_flowable/readme/DESCRIPTION.md b/stock_location_flowable/readme/DESCRIPTION.md new file mode 100644 index 000000000..f75fe770e --- /dev/null +++ b/stock_location_flowable/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +- Customizations that allow organizing, controlling, and mixing bulk + liquid and solid products in a location. diff --git a/stock_location_flowable/static/description/icon.png b/stock_location_flowable/static/description/icon.png new file mode 100644 index 000000000..1cd641e79 Binary files /dev/null and b/stock_location_flowable/static/description/icon.png differ diff --git a/stock_location_flowable/static/description/index.html b/stock_location_flowable/static/description/index.html new file mode 100644 index 000000000..04c436945 --- /dev/null +++ b/stock_location_flowable/static/description/index.html @@ -0,0 +1,425 @@ + + + + + +Stock Location Flowable + + + +
+

Stock Location Flowable

+ + +

Beta License: AGPL-3 NuoBiT/odoo-addons

+
    +
  • Customizations that allow organizing, controlling, and mixing bulk +liquid and solid products in a location.
  • +
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • NuoBiT Solutions SL
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the NuoBiT/odoo-addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/stock_location_flowable/tests/__init__.py b/stock_location_flowable/tests/__init__.py new file mode 100644 index 000000000..5c71e8f66 --- /dev/null +++ b/stock_location_flowable/tests/__init__.py @@ -0,0 +1,9 @@ +from . import test_stock_location +from . import test_common +from . import test_stock_picking +from . import test_stock_picking_type +from . import test_mrp_production +from . import test_stock_return_picking +from . import test_stock_quant +from . import test_stock_move +from . import test_stock_move_line diff --git a/stock_location_flowable/tests/test_common.py b/stock_location_flowable/tests/test_common.py new file mode 100644 index 000000000..23d6f5f2b --- /dev/null +++ b/stock_location_flowable/tests/test_common.py @@ -0,0 +1,330 @@ +# Copyright NuoBiT Solutions SL - Frank Cespedes +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging +import re + +from odoo.tests import common + +_logger = logging.getLogger(__name__) + + +class TestCommon(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") + + cls.picking_type_incoming_1 = cls.env["stock.picking.type"].create( + { + "name": "Receipt1", + "sequence_code": "SEQ-IN", + "code": "incoming", + "default_location_dest_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_outgoing_1 = cls.env["stock.picking.type"].create( + { + "name": "Delivery1", + "sequence_code": "SEQ-OUT", + "code": "outgoing", + "reservation_method": "manual", + "default_location_src_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_internal_1 = cls.env["stock.picking.type"].create( + { + "name": "InternalTransfer1", + "sequence_code": "SEQ-INT", + "code": "internal", + "default_location_src_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + "default_location_dest_id": cls.env.ref( + "stock.stock_location_locations_partner" + ).id, + } + ) + + cls.picking_type_mrp_operation_1 = cls.env["stock.picking.type"].create( + { + "name": "Production1", + "sequence_code": "SEQ-MRP", + "code": "mrp_operation", + "flowable_operation": False, + } + ) + + cls.product_flowable_1 = cls.env["product.product"].create( + { + "name": "Liquid O2", + "type": "consu", + "is_storable": True, + "uom_id": cls.env.ref("uom.product_uom_litre").id, + "uom_po_id": cls.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + + cls.product_flowable_2 = cls.env["product.product"].create( + { + "name": "Liquid N2", + "type": "consu", + "is_storable": True, + "uom_id": cls.env.ref("uom.product_uom_litre").id, + "uom_po_id": cls.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + + cls.location_1 = cls.env["stock.location"].create( + { + "name": "Warehouse Shelf", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + } + ) + + cls.location_flowable_1 = cls.env["stock.location"].create( + { + "name": "O2 Tank 1", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 1000, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [ + (4, cls.product_flowable_1.id), + (4, cls.product_flowable_2.id), + ], + } + ) + + cls.flowable_sequence = cls.env["ir.sequence"].create( + { + "name": "Test Flowable Sequence", + "code": "test.flowable.sequence", + "company_id": cls.env.company.id, + } + ) + + cls.location_flowable_2 = cls.env["stock.location"].create( + { + "name": "O2 Tank 2", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 1500, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], + "flowable_create_lots": True, + "flowable_sequence_id": cls.env.ref( + "stock.sequence_production_lots" + ).id, + } + ) + + lot_1 = cls.env["stock.lot"].create( + { + "name": "Lot1", + "product_id": cls.product_flowable_1.id, + } + ) + + cls.incoming_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_incoming_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.env["stock.move.line"].create( + { + "picking_id": cls.incoming_picking.id, + "product_id": cls.product_flowable_1.id, + "product_uom_id": cls.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": cls.supplier_location.id, + "location_dest_id": cls.incoming_picking.location_dest_id.id, + "company_id": cls.env.company.id, + } + ) + + cls.outgoing_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_outgoing_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.env["stock.move.line"].create( + { + "picking_id": cls.outgoing_picking.id, + "product_id": cls.product_flowable_1.id, + "product_uom_id": cls.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": cls.supplier_location.id, + "location_dest_id": cls.outgoing_picking.location_dest_id.id, + "company_id": cls.env.company.id, + } + ) + + cls.internal_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_internal_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + cls.env["stock.move.line"].create( + { + "picking_id": cls.internal_picking.id, + "product_id": cls.product_flowable_1.id, + "product_uom_id": cls.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": cls.env.ref("stock.stock_location_inter_company").id, + "location_dest_id": cls.internal_picking.location_dest_id.id, + "company_id": cls.env.company.id, + } + ) + + cls.mrp_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_mrp_operation_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + def get_error_message_regex(self, str1): + str1_esc = re.escape(str1) + str1_esc = re.sub(r"(%\\\([^)]+\\\)s|%s)", ".*", str1_esc) + return str1_esc + + def _create_lot(self, product, name): + return self.env["stock.lot"].create( + { + "name": name, + "product_id": product.id, + } + ) + + def _receive_stock(self, location, product, lot, qty, picking_type=None): + """Create and validate an incoming picking to any location.""" + if picking_type is None: + picking_type = self.picking_type_incoming_1 + picking = self.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "quantity": qty, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + return picking + + def _create_incoming_picking(self, location, product, lot, qty, picking_type=None): + """Create an incoming picking WITHOUT validating it.""" + if picking_type is None: + picking_type = self.picking_type_incoming_1 + picking = self.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_id": product.uom_id.id, + "lot_id": lot.id, + "quantity": qty, + "location_id": self.supplier_location.id, + "location_dest_id": location.id, + "company_id": self.env.company.id, + } + ) + return picking + + def _find_flowable_production(self, location, picking_type=None): + if picking_type is None: + picking_type = self.picking_type_mrp_operation_1 + return self.env["mrp.production"].search( + [ + ("picking_type_id", "=", picking_type.id), + ("location_dest_id", "=", location.id), + ], + order="id desc", + limit=1, + ) + + def _get_location_quants(self, location, product): + return self.env["stock.quant"].search( + [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ] + ) + + def _get_positive_quantity(self, location, product): + quants = self._get_location_quants(location, product) + return sum(quants.filtered(lambda q: q.quantity > 0).mapped("quantity")) + + def _seed_flowable_location( + self, location, product, lot, qty, picking_type=None, mrp_picking_type=None + ): + """Receive initial stock and complete the resulting MO.""" + picking = self._receive_stock( + location, product, lot, qty, picking_type=picking_type + ) + production = self._find_flowable_production( + location, picking_type=mrp_picking_type + ) + if production: + production.button_mark_done() + return picking + + def _create_inventory_adjustment(self, location, product, lot, qty): + """Create and validate a simple inventory adjustment (1 lot).""" + quant = ( + self.env["stock.quant"] + .with_context(inventory_mode=True) + .create( + { + "product_id": product.id, + "location_id": location.id, + "lot_id": lot.id, + "inventory_quantity": qty, + } + ) + ) + quant.action_apply_inventory() + return quant diff --git a/stock_location_flowable/tests/test_mrp_production.py b/stock_location_flowable/tests/test_mrp_production.py new file mode 100644 index 000000000..b95cfa10d --- /dev/null +++ b/stock_location_flowable/tests/test_mrp_production.py @@ -0,0 +1,1156 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare, float_round + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestMrpProduction(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_blocked_flowable_mrp_operation(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # ACT + self.incoming_picking.button_validate() + + # ASSERT + self.assertTrue(self.incoming_picking.location_dest_id.flowable_blocked) + + def test_block_new_production_flowable_location_by_outgoing_picking(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # ACT + with self.assertRaises(UserError) as error: + self.outgoing_picking.button_validate() + + # ASSERT + msg_error = ( + "The location %(location)s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_flowable_mixing_produces_single_positive_quant(self): + """ + Test that after completing a mixing MO, the flowable location has + exactly one quant with positive quantity (the mixed lot). + + PRE: - A flowable location with an initial lot quant (100 L) + ACT: - Receive 50 L at the flowable location (creates mixing MO) + - Complete the mixing MO + POST: - Exactly one quant with positive quantity remains + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self._create_lot(self.product_flowable_1, "TEST-INITIAL-LOT") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + lot_new = self._create_lot(self.product_flowable_1, "TEST-NEW-LOT") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production, "Mixing MO should have been created") + production.button_mark_done() + + # ASSERT + remaining_quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = remaining_quants.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one quant with positive quantity should remain (the mixed lot)", + ) + + def test_flowable_no_accumulated_error_after_multiple_mixing_cycles(self): + """ + Test that multiple mixing cycles produce a clean single quant. + + PRE: - A flowable location (capacity 1000 L) + ACT: - Perform 5 sequential mixing cycles with varying quantities + POST: - Only 1 quant with positive quantity exists + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + quantities = [50, 30, 45, 25, 20] + + for i, qty in enumerate(quantities): + lot = self._create_lot(self.product_flowable_1, f"TEST-MULTI-LOT-{i}") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot, qty + ) + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production, f"Mixing MO should be created on cycle {i + 1}") + production.button_mark_done() + + # ASSERT + remaining_quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = remaining_quants.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one quant should remain after multiple mixing cycles", + ) + + def test_flowable_old_quants_go_to_exact_zero(self): + """ + Test that when a mixing MO consumes old lot quants, they go to + exactly 0.0 (IEEE 754 exact) — not a near-zero residual. + + This verifies that the raw move lines use the exact quant quantities + (no float_round on individual consumption), so each quant is + decremented by exactly its own value → result is 0.0. + + PRE: - A flowable location with 1 lot at 100 L + ACT: - Receive 50 L (creates mixing MO, but don't complete it yet) + - Check old quant after MO raw moves are processed + POST: - Old lot quant quantity is exactly 0.0 (or cleaned up) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self._create_lot(self.product_flowable_1, "TEST-EXACT-ZERO-OLD") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + lot_new = self._create_lot(self.product_flowable_1, "TEST-EXACT-ZERO-NEW") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + production.button_mark_done() + + # ASSERT: old lot quant should not exist or be exactly 0 + old_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.location_flowable_1.id), + ("product_id", "=", self.product_flowable_1.id), + ("lot_id", "=", lot_initial.id), + ] + ) + if old_quant: + self.assertEqual( + old_quant.quantity, + 0.0, + "Old lot quant should be exactly 0.0, not a near-zero residual", + ) + + def test_flowable_mixing_with_fractional_quantities(self): + """ + Test that mixing works correctly with fractional quantities + that could introduce floating point errors (e.g., from UoM conversions). + + PRE: - A flowable location with a fractional lot quantity (33.333 L) + ACT: - Receive 16.667 L at the flowable location (creates mixing MO) + - Complete the mixing MO + POST: - Only 1 quant with positive quantity exists + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self._create_lot(self.product_flowable_1, "TEST-FRAC-INITIAL") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 33.333 + ) + + lot_new = self._create_lot(self.product_flowable_1, "TEST-FRAC-NEW") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_new, 16.667 + ) + production = self._find_flowable_production(self.location_flowable_1) + production.button_mark_done() + + # ASSERT + remaining_quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = remaining_quants.filtered(lambda q: q.quantity > 0) + self.assertEqual(len(positive_quants), 1) + + def test_flowable_mixing_does_not_affect_non_flowable_quants(self): + """ + Test that completing a mixing MO at a flowable location does not + affect quants at other (non-flowable) locations. + + PRE: - A flowable location with stock + - A non-flowable location with stock of the same product + ACT: - Complete a mixing MO at the flowable location + POST: - Non-flowable location quants are unaffected + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_initial = self._create_lot(self.product_flowable_1, "TEST-NONFLO-INITIAL") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + # Stock at non-flowable location + lot_other = self._create_lot(self.product_flowable_1, "TEST-NONFLO-OTHER") + self._receive_stock(self.location_1, self.product_flowable_1, lot_other, 200) + + lot_new = self._create_lot(self.product_flowable_1, "TEST-NONFLO-NEW") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + production.button_mark_done() + + # ASSERT: non-flowable location quant is untouched + other_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.location_1.id), + ("product_id", "=", self.product_flowable_1.id), + ("lot_id", "=", lot_other.id), + ] + ) + self.assertEqual( + other_quant.quantity, + 200, + "Non-flowable location quant should not be affected by mixing MO", + ) + + def test_flowable_qty_producing_is_rounded(self): + """ + Test that the mixing MO's qty_producing is properly rounded to the + product's UoM rounding, so that _post_inventory produces a clean + rounded finished quantity. + + PRE: - A flowable location with stock + ACT: - Receive new stock (creates mixing MO) + POST: - MO's qty_producing == float_round(sum(quants), uom_rounding) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + rounding = self.product_flowable_1.uom_id.rounding + + lot_initial = self._create_lot(self.product_flowable_1, "TEST-ROUND-INITIAL") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_initial, 100 + ) + + lot_new = self._create_lot(self.product_flowable_1, "TEST-ROUND-NEW") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_new, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + + # ASSERT: qty_producing is properly rounded + expected_qty = float_round(150.0, precision_rounding=rounding) + self.assertEqual( + production.qty_producing, + expected_qty, + f"qty_producing should be float_round({150.0}, rounding={rounding})" + f" = {expected_qty}, got {production.qty_producing}", + ) + + def test_flowable_mixing_with_custom_uom_rounding(self): + """ + Test the mixing process with a custom UoM that has fine rounding + (0.001), simulating the customer's Litro(s) O2 configuration. + + Also tests with quantities that result from a Kg→Litro conversion + (ratio 1.141) to exercise realistic float arithmetic. + + PRE: - Custom UoM category with Litro O2 (rounding=0.001) + - Product configured with this UoM + - Flowable location configured with this UoM + - Initial quant with a Kg-converted value + - Decimal precision set to 5 (accommodates UoM rounding) + ACT: - Receive new stock with another Kg-converted quantity + - Complete the mixing MO + POST: - Final stock is correct within UoM precision + """ + # ARRANGE: Increase decimal precision to accommodate UoM rounding (0.001) + dp = self.env.ref("product.decimal_product_uom") + dp.digits = 5 + + # Custom UoM with fine rounding (like customer's Litro O2) + uom_category_o2 = self.env["uom.category"].create({"name": "Liquid O2"}) + uom_litro_o2 = self.env["uom.uom"].create( + { + "name": "Litre O2", + "category_id": uom_category_o2.id, + "uom_type": "reference", + "rounding": 0.001, + } + ) + rounding = uom_litro_o2.rounding # 0.001 + + product_o2 = self.env["product.product"].create( + { + "name": "Liquid O2", + "type": "consu", + "is_storable": True, + "uom_id": uom_litro_o2.id, + "uom_po_id": uom_litro_o2.id, + "tracking": "lot", + } + ) + + location_cistern = self.env["stock.location"].create( + { + "name": "O2 Tank 6", + "usage": "internal", + "location_id": self.env.ref( + "stock.stock_location_locations_partner" + ).id, + "flowable_storage": True, + "flowable_capacity": 5000, + "flowable_uom_id": uom_litro_o2.id, + "flowable_allowed_product_ids": [(4, product_o2.id)], + "flowable_create_lots": True, + "flowable_sequence_id": self.env.ref( + "stock.sequence_production_lots" + ).id, + } + ) + + # Simulate quant from a Kg→Litro conversion: 657.93 Kg / 1.141 + # = 576.6257668712... L → rounded to 576.626 + kg_delivery_1 = 657.93 + litres_1 = float_round( + kg_delivery_1 / 1.141, precision_rounding=rounding + ) # 576.626 + + lot_initial = self._create_lot(product_o2, "TEST-O2-INITIAL") + self.picking_type_mrp_operation_1.flowable_operation = True + + self._seed_flowable_location( + location_cistern, product_o2, lot_initial, litres_1 + ) + + # Second delivery: 583.21 Kg / 1.141 = 511.1393... → 511.139 + kg_delivery_2 = 583.21 + litres_2 = float_round( + kg_delivery_2 / 1.141, precision_rounding=rounding + ) # 511.139 + + lot_new = self._create_lot(product_o2, "TEST-O2-NEW") + + # ACT: Receive at the cistern + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": location_cistern.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product_o2.id, + "product_uom_id": uom_litro_o2.id, + "lot_id": lot_new.id, + "quantity": litres_2, + "location_id": self.supplier_location.id, + "location_dest_id": location_cistern.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + # Find and complete the mixing MO + production = self._find_flowable_production(location_cistern) + self.assertTrue(production, "Mixing MO should have been created") + production.button_mark_done() + + # ASSERT + remaining = self.env["stock.quant"].search( + [ + ("location_id", "=", location_cistern.id), + ("product_id", "=", product_o2.id), + ] + ) + positive_quants = remaining.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one positive quant should remain (the mixed lot)", + ) + expected_total = litres_1 + litres_2 + self.assertEqual( + float_compare( + positive_quants.quantity, + expected_total, + precision_rounding=rounding, + ), + 0, + f"Final stock {positive_quants.quantity} should equal total" + f" {expected_total} within UoM rounding {rounding}", + ) + + def test_flowable_mixing_multiple_cycles_fine_rounding(self): + """ + Test multiple mixing cycles with fine UoM rounding (0.001) and + Kg→Litro converted quantities, verifying no error accumulation. + + Simulates 5 sequential deliveries converted from Kg O2 to Litro O2 + (ratio 1.141), ensuring the flowable location stays clean. + + PRE: - Custom UoM with rounding=0.001 + - Decimal precision set to 5 (accommodates UoM rounding) + ACT: - 5 sequential receive → mix → complete cycles + POST: - Only 1 quant with positive quantity remains + - Final stock within UoM precision of sum of deliveries + """ + # ARRANGE: Increase decimal precision to accommodate UoM rounding (0.001) + dp = self.env.ref("product.decimal_product_uom") + dp.digits = 5 + + self.picking_type_mrp_operation_1.flowable_operation = True + + uom_category_o2 = self.env["uom.category"].create({"name": "Liquid O2"}) + uom_litro_o2 = self.env["uom.uom"].create( + { + "name": "Litre O2", + "category_id": uom_category_o2.id, + "uom_type": "reference", + "rounding": 0.001, + } + ) + rounding = uom_litro_o2.rounding + + product_o2 = self.env["product.product"].create( + { + "name": "Liquid O2", + "type": "consu", + "is_storable": True, + "uom_id": uom_litro_o2.id, + "uom_po_id": uom_litro_o2.id, + "tracking": "lot", + } + ) + + location_cistern = self.env["stock.location"].create( + { + "name": "O2 Tank 6", + "usage": "internal", + "location_id": self.env.ref( + "stock.stock_location_locations_partner" + ).id, + "flowable_storage": True, + "flowable_capacity": 10000, + "flowable_uom_id": uom_litro_o2.id, + "flowable_allowed_product_ids": [(4, product_o2.id)], + "flowable_create_lots": True, + "flowable_sequence_id": self.env.ref( + "stock.sequence_production_lots" + ).id, + } + ) + + # Simulate 5 deliveries in Kg, converted to Litres + kg_deliveries = [657.93, 583.21, 492.84, 701.23, 515.67] + + for i, kg in enumerate(kg_deliveries): + litres = float_round(kg / 1.141, precision_rounding=rounding) + + lot = self._create_lot(product_o2, f"TEST-O2-MULTI-{i}") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": location_cistern.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": product_o2.id, + "product_uom_id": uom_litro_o2.id, + "lot_id": lot.id, + "quantity": litres, + "location_id": self.supplier_location.id, + "location_dest_id": location_cistern.id, + "company_id": self.env.company.id, + } + ) + picking.button_validate() + + production = self._find_flowable_production(location_cistern) + self.assertTrue( + production, f"Mixing MO should be created on delivery {i + 1}" + ) + production.button_mark_done() + + # ASSERT + remaining = self.env["stock.quant"].search( + [ + ("location_id", "=", location_cistern.id), + ("product_id", "=", product_o2.id), + ] + ) + positive_quants = remaining.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), + 1, + "Only one positive quant should remain after 5 cycles", + ) + expected_total = sum( + float_round(kg / 1.141, precision_rounding=rounding) for kg in kg_deliveries + ) + self.assertEqual( + float_compare( + positive_quants.quantity, + expected_total, + precision_rounding=rounding, + ), + 0, + f"Final stock {positive_quants.quantity} should equal total" + f" {expected_total} within UoM rounding {rounding}", + ) + + def test_production_flowable_computed(self): + """ + Test that production_flowable is True when the picking type is flowable. + + PRE: - A flowable MO created from a picking + ACT: - Read production_flowable + POST: - production_flowable is True + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + + # ACT & ASSERT + self.assertTrue(production.production_flowable) + + def test_production_flowable_false_for_non_flowable_type(self): + """ + Test that production_flowable is False when picking type is not flowable. + + PRE: - A standard MO (non-flowable picking type) + ACT: - Read production_flowable + POST: - production_flowable is False + """ + # ARRANGE + production = self.env["mrp.production"].create( + { + "product_id": self.product_flowable_1.id, + "product_qty": 10, + "product_uom_id": self.product_flowable_1.uom_id.id, + "picking_type_id": self.picking_type_mrp_operation_1.id, + "location_src_id": self.location_flowable_1.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + # ACT & ASSERT + self.assertFalse(production.production_flowable) + + def test_production_blocked_computed(self): + """ + Test that production_blocked is True when a location references the MO. + + PRE: - A flowable location with a production linked + ACT: - Read production_blocked + POST: - production_blocked is True + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + + # ACT & ASSERT + self.assertTrue(production.production_blocked) + + def test_cancel_production_with_picking_raises_error(self): + """ + Test that cancelling a production with a picking associated raises + an error. + + PRE: - A flowable MO with a picking_id set + ACT: - Try to cancel the MO + POST: - An error is raised because the picking is associated + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production.picking_id) + + # ACT & ASSERT + # The cancel flow triggers stock_move.write which also blocks + # modification, so we expect either UserError or ValidationError + with self.assertRaises(Exception) as error: + production.action_cancel() + + # Verify the error is related to the mixing being in progress + self.assertIn("mixing is in progress", str(error.exception)) + + def test_write_to_close_production_with_picking_raises_error(self): + """ + Test that modifying a to_close production with a picking raises + a ValidationError. + + PRE: - A flowable MO in to_close state with a picking_id + ACT: - Try to write to the MO + POST: - ValidationError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production.picking_id) + self.assertEqual(production.state, "to_close") + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + production.write({"product_qty": 999}) + + msg_error = ( + "You cannot modify a mix production with a picking associated." + " The mixing is in progress." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_post_production_quant_check_create_lots_false(self): + """ + Test that the defensive post-production quant check passes when + create_lots=False (incoming lot becomes the producing lot). + + PRE: - Flowable location (create_lots=False) seeded with lot A + ACT: - Receive lot B, complete the mixing MO + POST: - No error is raised (all non-producing lots have 0 stock) + - Only the producing lot has positive stock + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "POST-CHECK-A") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_a, 100 + ) + + lot_b = self._create_lot(self.product_flowable_1, "POST-CHECK-B") + + # ACT + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_b, 50 + ) + production = self._find_flowable_production(self.location_flowable_1) + # button_mark_done triggers _check_flowable_post_production_quants + production.button_mark_done() + + # ASSERT — producing lot is the incoming lot (create_lots=False) + self.assertEqual(production.lot_producing_id, lot_b) + quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = quants.filtered(lambda q: q.quantity > 0) + self.assertEqual(len(positive_quants), 1) + self.assertEqual(positive_quants.lot_id, lot_b) + + def test_post_production_quant_check_create_lots_true(self): + """ + Test that the defensive post-production quant check passes when + create_lots=True (auto-generated lot becomes the producing lot). + + PRE: - Flowable location (create_lots=True) seeded with lot A + ACT: - Receive lot B, complete the mixing MO + POST: - No error is raised + - Only the auto-generated producing lot has positive stock + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "POST-CHECK-AUTO-A") + self._seed_flowable_location( + self.location_flowable_2, self.product_flowable_1, lot_a, 100 + ) + + lot_b = self._create_lot(self.product_flowable_1, "POST-CHECK-AUTO-B") + + # ACT + self._receive_stock( + self.location_flowable_2, self.product_flowable_1, lot_b, 50 + ) + production = self._find_flowable_production(self.location_flowable_2) + production.button_mark_done() + + # ASSERT — producing lot is auto-generated (different from both A and B) + self.assertNotEqual(production.lot_producing_id, lot_a) + self.assertNotEqual(production.lot_producing_id, lot_b) + quants = self._get_location_quants( + self.location_flowable_2, self.product_flowable_1 + ) + positive_quants = quants.filtered(lambda q: q.quantity > 0) + self.assertEqual(len(positive_quants), 1) + self.assertEqual(positive_quants.lot_id, production.lot_producing_id) + + def test_production_without_bom_allowed_for_flowable(self): + """ + Test that a flowable production can be created without a Bill of + Materials because _check_production_lines is bypassed. + + PRE: - A flowable mrp_operation picking type + ACT: - Create a production without BoM + POST: - Production is created successfully + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # ACT + production = self.env["mrp.production"].create( + { + "product_id": self.product_flowable_1.id, + "product_qty": 10, + "product_uom_id": self.product_flowable_1.uom_id.id, + "picking_type_id": self.picking_type_mrp_operation_1.id, + "location_src_id": self.location_flowable_1.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + # ASSERT + self.assertTrue(production) + + +class TestFlowableReservationConflictFromProduction(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.picking_type_mrp = cls.env["stock.picking.type"].create( + { + "name": "TestFlowableProd", + "sequence_code": "SEQ-FLPROD", + "code": "mrp_operation", + "flowable_operation": True, + } + ) + + cls.location_flowable_3 = cls.env["stock.location"].create( + { + "name": "O2 Tank 3", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 5000, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], + } + ) + + def test_reservation_conflict_from_production_move(self): + """ + Test that receiving stock at a flowable location is rejected when + a regular (non-flowable) MO has reserved stock from that location. + + This covers the `elif move.raw_material_production_id` branch in + _check_flowable_reservation (moves that belong to another MO, + not to a picking). + + PRE: - Flowable location 'O2 Tank 3' with 500 L of lot A (seeded) + - A finished product with a BoM consuming 100 L of the + flowable product + - A regular MO confirmed + assigned (reserves 100 L) + ACT: - Receive 200 L of lot B at 'O2 Tank 3' + POST: - UserError is raised mentioning the regular MO + """ + # ARRANGE — seed the flowable location + lot_a = self._create_lot(self.product_flowable_1, "RES-LOT-A") + self._seed_flowable_location( + self.location_flowable_3, + self.product_flowable_1, + lot_a, + 500, + mrp_picking_type=self.picking_type_mrp, + ) + self.assertFalse(self.location_flowable_3.flowable_blocked) + + # Create a finished product with a BoM + finished_product = self.env["product.product"].create( + { + "name": "Finished Product", + "type": "consu", + "is_storable": True, + "uom_id": self.env.ref("uom.product_uom_unit").id, + "uom_po_id": self.env.ref("uom.product_uom_unit").id, + } + ) + bom = self.env["mrp.bom"].create( + { + "product_tmpl_id": finished_product.product_tmpl_id.id, + "product_qty": 1, + "bom_line_ids": [ + ( + 0, + 0, + { + "product_id": self.product_flowable_1.id, + "product_qty": 100, + }, + ) + ], + } + ) + + # Create a regular MO sourcing directly from the flowable location + regular_picking_type = self.env["stock.picking.type"].search( + [ + ("warehouse_id", "=", self.picking_type_incoming_1.warehouse_id.id), + ("code", "=", "mrp_operation"), + ("flowable_operation", "=", False), + ], + limit=1, + ) + if not regular_picking_type: + regular_picking_type = self.env["stock.picking.type"].create( + { + "name": "RegularProduction", + "sequence_code": "SEQ-REGPROD", + "code": "mrp_operation", + } + ) + regular_mo = self.env["mrp.production"].create( + { + "product_id": finished_product.id, + "bom_id": bom.id, + "product_qty": 1, + "product_uom_id": finished_product.uom_id.id, + "picking_type_id": regular_picking_type.id, + "location_src_id": self.location_flowable_3.id, + "location_dest_id": self.location_flowable_3.location_id.id, + } + ) + regular_mo.action_confirm() + regular_mo.action_assign() + + # Verify the regular MO reserved stock at the flowable location + raw_moves = regular_mo.move_raw_ids.filtered( + lambda m: m.product_id == self.product_flowable_1 + ) + self.assertTrue( + all(m.state == "assigned" for m in raw_moves), + "Regular MO raw moves should be fully assigned (reserved)", + ) + + # ACT — receive new stock at the flowable location + lot_b = self._create_lot(self.product_flowable_1, "RES-LOT-B") + with self.assertRaises(UserError) as error: + self._receive_stock( + self.location_flowable_3, self.product_flowable_1, lot_b, 200 + ) + + # ASSERT — error should mention the regular MO + self.assertIn(regular_mo.name, str(error.exception)) + + +class TestFlowableBlockingWithReservations(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.picking_type_mrp = cls.env["stock.picking.type"].create( + { + "name": "TestProduction", + "sequence_code": "SEQ-TEST-MRP", + "code": "mrp_operation", + "flowable_operation": True, + } + ) + + cls.location_flowable_4 = cls.env["stock.location"].create( + { + "name": "O2 Tank 4", + "usage": "internal", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + "flowable_storage": True, + "flowable_capacity": 15000, + "flowable_uom_id": cls.env.ref("uom.product_uom_litre").id, + "flowable_allowed_product_ids": [(4, cls.product_flowable_1.id)], + } + ) + + def _create_sale_picking( + self, + location, + product, + name, + qty, + reserve=True, + unreserve=False, + ): + customer_location = self.env.ref("stock.stock_location_customers") + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_outgoing_1.id, + "location_id": location.id, + "location_dest_id": customer_location.id, + } + ) + self.env["stock.move"].create( + { + "name": name, + "picking_id": picking.id, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": qty, + "location_id": location.id, + "location_dest_id": customer_location.id, + } + ) + picking.action_confirm() + if reserve: + picking.action_assign() + if unreserve: + picking.do_unreserve() + return picking + + def test_flowable_blocking_with_pending_reservations_and_reception(self): + """ + Test that receiving stock at a flowable location is rejected when + there are reserved quantities that would become invalid after the + merge (the current lot goes to 0 stock). + + Sales 1 and 3 are explicitly reserved (assigned). Sale 2 is only + confirmed (not reserved). The mixing MO cannot fully reserve + because Sales 1 and 3 hold reservations on the stock. + + PRE: - Flowable location 'O2 Tank 4' (capacity 15000 L), initially empty + - Receive 7000 L of lot X1 via reception + MO (seed) + - Sale 1: 100 L of X1 confirmed + reserved (assigned) + - Sale 2: 200 L of X1 confirmed only (not reserved) + - Inventory adjustment: +1000 L on lot X1 + - Sale 3: 600 L of X1 confirmed + reserved (assigned) + ACT: - Receive 5000 L of lot P1 at 'O2 Tank 4' + POST: - UserError is raised mentioning Sales 1 and 3 + (the only ones with active reservations) + """ + # ARRANGE + lot_x1 = self._create_lot(self.product_flowable_1, "X1") + self._seed_flowable_location( + self.location_flowable_4, + self.product_flowable_1, + lot_x1, + 7000, + mrp_picking_type=self.picking_type_mrp, + ) + self.assertEqual( + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), + 7000, + ) + self.assertFalse(self.location_flowable_4.flowable_blocked) + + # Sale 1: 100 L of X1, confirmed + reserved (assigned) + sale_picking_1 = self._create_sale_picking( + self.location_flowable_4, self.product_flowable_1, "Sale 1 - X1 100L", 100 + ) + self.assertEqual(sale_picking_1.state, "assigned") + + # Sale 2: 200 L of X1, confirmed only (not reserved) + sale_picking_2 = self._create_sale_picking( + self.location_flowable_4, + self.product_flowable_1, + "Sale 2 - X1 200L", + 200, + reserve=False, + ) + self.assertEqual(sale_picking_2.state, "confirmed") + + # Inventory adjustment: +1000 L on lot X1 + existing_quant = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product_flowable_1.id), + ("location_id", "=", self.location_flowable_4.id), + ("lot_id", "=", lot_x1.id), + ], + limit=1, + ) + existing_quant.with_context(inventory_mode=True).write( + {"inventory_quantity": existing_quant.quantity + 1000} + ) + existing_quant.action_apply_inventory() + self.assertEqual( + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), + 8000, + ) + + # Sale 3: 600 L of X1, confirmed + reserved (assigned) + sale_picking_3 = self._create_sale_picking( + self.location_flowable_4, self.product_flowable_1, "Sale 3 - X1 600L", 600 + ) + self.assertEqual(sale_picking_3.state, "assigned") + self.assertFalse(self.location_flowable_4.flowable_blocked) + + # ACT + lot_p1 = self._create_lot(self.product_flowable_1, "P1") + with self.assertRaises(UserError) as error: + self._receive_stock( + self.location_flowable_4, self.product_flowable_1, lot_p1, 5000 + ) + + # ASSERT + # Only Sales 1 and 3 appear in the error (the ones with active + # reservations). Sale 2 is only confirmed, not reserved. + expected_msg = ( + "Cannot merge at flowable location 'O2 Tank 4'" + " because there are reserved quantities." + " After the merge, the current lot(s) will" + " have 0 stock and these reservations will" + " become invalid.\n\n" + "The following operations must be unreserved" + " or completed first:\n\n" + " - Liquid O2: 100.0 L (lot X1)" + f" - {sale_picking_1.name} (Delivery1)\n" + " - Liquid O2: 600.0 L (lot X1)" + f" - {sale_picking_3.name} (Delivery1)" + ) + self.assertEqual(str(error.exception), expected_msg) + + def test_flowable_merge_succeeds_with_unreserved_operations(self): + """ + Test that receiving stock at a flowable location succeeds when + all sales have been unreserved before the reception. + + The unreserved sales stay confirmed. The mixing MO fully reserves + all stock and succeeds. + + PRE: - Flowable location 'O2 Tank 4' (capacity 15000 L), initially empty + - Receive 7000 L of lot X1 via reception + MO (seed) + - Sale 1: 100 L of X1 reserved then unreserved + - Sale 2: 200 L of X1 reserved then unreserved + - Inventory adjustment: +1000 L on lot X1 + - Sale 3: 600 L of X1 reserved then unreserved + ACT: - Receive 5000 L of lot P1 at 'O2 Tank 4' + POST: - No error is raised + - A mixing MO is created and 'O2 Tank 4' is blocked + """ + # ARRANGE + lot_x1 = self._create_lot(self.product_flowable_1, "X1") + self._seed_flowable_location( + self.location_flowable_4, + self.product_flowable_1, + lot_x1, + 7000, + mrp_picking_type=self.picking_type_mrp, + ) + self.assertEqual( + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), + 7000, + ) + self.assertFalse(self.location_flowable_4.flowable_blocked) + + # Sale 1: 100 L of X1, reserved then unreserved + sale_picking_1 = self._create_sale_picking( + self.location_flowable_4, + self.product_flowable_1, + "Sale 1 - X1 100L", + 100, + unreserve=True, + ) + self.assertEqual(sale_picking_1.state, "confirmed") + + # Sale 2: 200 L of X1, reserved then unreserved + sale_picking_2 = self._create_sale_picking( + self.location_flowable_4, + self.product_flowable_1, + "Sale 2 - X1 200L", + 200, + unreserve=True, + ) + self.assertEqual(sale_picking_2.state, "confirmed") + + # Inventory adjustment: +1000 L on lot X1 + existing_quant = self.env["stock.quant"].search( + [ + ("product_id", "=", self.product_flowable_1.id), + ("location_id", "=", self.location_flowable_4.id), + ("lot_id", "=", lot_x1.id), + ], + limit=1, + ) + existing_quant.with_context(inventory_mode=True).write( + {"inventory_quantity": existing_quant.quantity + 1000} + ) + existing_quant.action_apply_inventory() + self.assertEqual( + self._get_positive_quantity( + self.location_flowable_4, self.product_flowable_1 + ), + 8000, + ) + + # Sale 3: 600 L of X1, reserved then unreserved + sale_picking_3 = self._create_sale_picking( + self.location_flowable_4, + self.product_flowable_1, + "Sale 3 - X1 600L", + 600, + unreserve=True, + ) + self.assertEqual(sale_picking_3.state, "confirmed") + + # Verify no reserved quantities remain at 'O2 Tank 4' + quants = self._get_location_quants( + self.location_flowable_4, self.product_flowable_1 + ) + self.assertFalse(any(q.reserved_quantity > 0 for q in quants)) + self.assertFalse(self.location_flowable_4.flowable_blocked) + + # ACT + lot_p1 = self._create_lot(self.product_flowable_1, "P1") + self._receive_stock( + self.location_flowable_4, self.product_flowable_1, lot_p1, 5000 + ) + + # ASSERT + production = self._find_flowable_production( + self.location_flowable_4, picking_type=self.picking_type_mrp + ) + self.assertTrue(production, "A mixing MO should have been created") + self.assertTrue( + self.location_flowable_4.flowable_blocked, + "'O2 Tank 4' should be blocked after reception triggers a mixing MO", + ) diff --git a/stock_location_flowable/tests/test_stock_location.py b/stock_location_flowable/tests/test_stock_location.py new file mode 100644 index 000000000..65c1f899f --- /dev/null +++ b/stock_location_flowable/tests/test_stock_location.py @@ -0,0 +1,742 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError, ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockLocation(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.uom_litre = cls.env.ref("uom.product_uom_litre") + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.product_flowable_1 = cls.env["product.product"].create( + { + "name": "Liquid CO2", + "type": "consu", + "is_storable": True, + "uom_id": cls.uom_litre.id, + "uom_po_id": cls.uom_litre.id, + } + ) + cls.location_flowable_bcn_1 = cls.env["stock.location"].create( + { + "name": "O2 Tank 5", + "location_id": cls.env.ref("stock.stock_location_locations_partner").id, + } + ) + + # check + def test_required_field_flowable_capacity(self): + """ + Test to ensure that 'flowable_capacity' field is required when + 'flowable_storage' is True. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage' + but without 'flowable_capacity' + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + } + ) + + # ASSERT + msg_error = "Capacity must be greater than 0" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_required_field_flowable_uom_id(self): + """ + Test to ensure that 'flowable_uom_id' field is required when 'flowable_storage' + is True. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + - 'flowable_capacity' is set + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage' + and 'flowable_capacity' but without 'flowable_uom_id' + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + } + ) + + # ASSERT + msg_error = "You must select a unit of measure" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_required_field_flowable_allowed_product_ids(self): + """ + Test to ensure that 'flowable_allowed_product_ids' field is required when + 'flowable_storage' is True. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + - 'flowable_capacity' is set + - 'flowable_uom_id' is set + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage', + 'flowable_capacity' and + 'flowable_uom_id' but without 'flowable_allowed_product_ids' + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + } + ) + + # ASSERT + msg_error = "You must select products" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_flowable_allowed_product_ids_tracked_by_lot(self): + """ + Test to ensure that 'flowable_allowed_product_ids' field has products tracked + by lot. + + PRE: - location_flowable_bcn_1 exists + - 'flowable_storage' is set to True + - 'flowable_capacity' is set + - 'flowable_uom_id' is set + - 'flowable_allowed_product_ids' is set + ACT: - Attempt to write to location_flowable_bcn_1 with 'flowable_storage', + 'flowable_capacity', + 'flowable_uom_id' and 'flowable_allowed_product_ids' but without + products tracked by lot + POST: - ValidationError is raised + """ + # ARRANGE & ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ASSERT + msg_error = "All allowed products must be tracked by lot" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_successful_flowable_location_update(self): + """ + Test to ensure that a location can be successfully updated with all required + fields. + + PRE: - location_flowable_bcn_1 exists + - Necessary fields are prepared (uom_litre, product_flowable_1) + ACT: - Write to location_flowable_bcn_1 with all required fields + POST: - No errors are raised + - All fields are correctly updated + """ + # ARRANGE + self.product_flowable_1.tracking = "lot" + + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ACT & ASSERT + self.assertTrue(self.location_flowable_bcn_1.flowable_storage) + self.assertEqual(self.location_flowable_bcn_1.flowable_capacity, 100.0) + self.assertEqual( + self.location_flowable_bcn_1.flowable_uom_id.id, self.uom_litre.id + ) + self.assertIn( + self.product_flowable_1.id, + self.location_flowable_bcn_1.flowable_allowed_product_ids.ids, + ) + + # check + def test_adding_product_with_incompatible_uom(self): + """ + Test to ensure that adding a product with a unit of measure different from + 'flowable_uom_id' + raises an error. + + PRE: - Location exists with 'flowable_storage' set to True and a certain + 'flowable_uom_id' + - A product with a different 'uom_id' exists + ACT: - Attempt to add this product to 'flowable_allowed_product_ids' + POST: - ValidationError is raised stating that only products with the allowed + unit of measure can be assigned + """ + # ARRANGE + product_flowable_2 = self.env["product.product"].create( + { + "name": "CO2 Cylinder", + "type": "consu", + "is_storable": True, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "tracking": "lot", + } + ) + + # ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, product_flowable_2.id)], + } + ) + + # ASSERT + msg_error = ( + "The product %(product)s is measured in %(uom)s. You can only assign" + " products that have the allowed unit of measure" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_changing_to_incompatible_uom_id(self): + """ + Test to ensure that changing 'flowable_uom_id' to a unit of measure different + from that of + allowed products raises an error. + + PRE: - Location exists with 'flowable_storage' set to True and a certain + 'flowable_uom_id' + - 'flowable_allowed_product_ids' contains products with the current + 'flowable_uom_id' + ACT: - Attempt to change 'flowable_uom_id' to a different unit of measure + POST: - ValidationError is raised stating that only products + with the allowed unit of measure can be assigned + """ + # ARRANGE + product_flowable_2 = self.env["product.product"].create( + { + "name": "CO2 Cylinder", + "type": "consu", + "is_storable": True, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "tracking": "lot", + } + ) + + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_unit.id, + "flowable_allowed_product_ids": [(4, product_flowable_2.id)], + } + ) + + # ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_uom_id": self.uom_litre.id, + } + ) + + # ASSERT + msg_error = ( + "The product %(product)s is measured in %(uom)s. You can only assign" + " products that have the allowed unit of measure" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + # check + def test_check_flowable_sequence_id(self): + # ARRANGE + self.product_flowable_1.tracking = "lot" + + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ACT + with self.assertRaises(ValidationError) as error: + self.location_flowable_bcn_1.write( + { + "flowable_create_lots": True, + } + ) + + # ASSERT + msg_error = "You must select a sequence" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_disable_flowable_storage_clears_fields(self): + """ + Test that disabling flowable_storage on a location clears all + flowable-related fields. + + PRE: - A fully configured flowable location + ACT: - Set flowable_storage to False + POST: - All flowable fields are cleared + """ + # ARRANGE + self.product_flowable_1.tracking = "lot" + self.location_flowable_bcn_1.write( + { + "flowable_storage": True, + "flowable_capacity": 100.0, + "flowable_uom_id": self.uom_litre.id, + "flowable_allowed_product_ids": [(4, self.product_flowable_1.id)], + } + ) + + # ACT + self.location_flowable_bcn_1.write({"flowable_storage": False}) + + # ASSERT + self.assertFalse(self.location_flowable_bcn_1.flowable_storage) + self.assertEqual(self.location_flowable_bcn_1.flowable_capacity, 0) + self.assertFalse(self.location_flowable_bcn_1.flowable_uom_id) + self.assertFalse(self.location_flowable_bcn_1.flowable_sequence_id) + + def test_cannot_disable_flowable_on_blocked_location(self): + """ + Test that disabling flowable_storage on a blocked location + (one with a production linked) raises an error. + + PRE: - A flowable location with a production linked + ACT: - Attempt to set flowable_storage to False + POST: - ValidationError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.location_flowable_1.write({"flowable_storage": False}) + + msg_error = "You cannot disable flowable storage from a blocked location." + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_flowable_blocked_popover(self): + """ + Test that the flowable_blocked_popover computed field returns + a JSON string with the expected warning message. + + PRE: - A flowable location + ACT: - Read flowable_blocked_popover + POST: - Contains expected title and message + """ + # ACT & ASSERT + popover = self.location_flowable_1.flowable_blocked_popover + self.assertIn("Flowable Location Warning", popover) + self.assertIn("manufacturing order", popover) + + def test_flowable_capacity_occupied(self): + """ + Test that flowable_capacity_occupied computes the sum of quant quantities. + + PRE: - A flowable location with stock + ACT: - Receive stock and check capacity_occupied + POST: - capacity_occupied reflects the stock + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-CAP-LOT") + self._receive_stock(self.location_flowable_1, product, lot, 100) + + # ACT + production = self._find_flowable_production(self.location_flowable_1) + production.button_mark_done() + + # ASSERT + self.location_flowable_1.invalidate_recordset() + self.assertGreater(self.location_flowable_1.flowable_capacity_occupied, 0) + + def test_flowable_percentage_occupied(self): + """ + Test that flowable_percentage_occupied is calculated correctly. + + PRE: - A flowable location with capacity=1000 and no stock + ACT: - Read percentage_occupied + POST: - percentage is 0 when empty + """ + # ACT & ASSERT + self.assertEqual(self.location_flowable_1.flowable_percentage_occupied, 0) + + def test_flowable_percentage_zero_capacity(self): + """ + Test that flowable_percentage_occupied returns 0 when capacity is 0. + + PRE: - A non-flowable location with capacity=0 + ACT: - Read flowable_percentage_occupied + POST: - Returns 0 (no division by zero) + """ + # ACT & ASSERT + self.assertEqual(self.location_flowable_bcn_1.flowable_percentage_occupied, 0) + + def test_action_view_mrp_production(self): + """ + Test that the action_view_mrp_production method returns an action + pointing to the linked production. + + PRE: - A flowable location with a production linked + ACT: - Call action_view_mrp_production + POST: - Action res_id matches the production + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self.location_flowable_1.flowable_production_id + + # ACT + action = self.location_flowable_1.action_view_mrp_production() + + # ASSERT + self.assertEqual(action["res_id"], production.id) + self.assertEqual(action["res_model"], "mrp.production") + + def test_complete_name_blocked(self): + """ + Test that the complete_name of a blocked flowable location + includes '[Blocked]'. + + PRE: - A flowable location that is blocked + ACT: - Read complete_name + POST: - Contains 'Blocked' + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # ACT & ASSERT + self.assertIn("Blocked", self.location_flowable_1.complete_name) + + def test_name_get_blocked(self): + """ + Test that name_get for a blocked flowable location includes '[Blocked]'. + + PRE: - A flowable location that is blocked + ACT: - Call name_get + POST: - Display name contains '[Blocked]' + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # ACT + result = self.location_flowable_1.name_get() + + # ASSERT + self.assertIn("[Blocked]", result[0][1]) + + def test_name_get_not_blocked(self): + """ + Test that name_get for a non-blocked flowable location does NOT + include '[Blocked]'. + + PRE: - A flowable location that is not blocked + ACT: - Call name_get + POST: - Display name does not contain '[Blocked]' + """ + # ACT + result = self.location_flowable_1.name_get() + + # ASSERT + self.assertNotIn("[Blocked]", result[0][1]) + + def test_cannot_remove_stored_product_from_allowed(self): + """ + Test that removing a product from flowable_allowed_product_ids + raises an error when stock of that product exists. + + PRE: - A flowable location with stock of a product + ACT: - Try to remove that product from allowed list + POST: - UserError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-REMOVE-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 50) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.location_flowable_1.write( + { + "flowable_allowed_product_ids": [ + (3, product.id), + ], + } + ) + + msg_error = ( + "You cannot remove a product that is currently stored in this location." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_capacity_full_raises_error(self): + """ + Test that receiving stock that exceeds the flowable location + capacity triggers the capacity constraint. + + PRE: - A flowable location with capacity 100 + ACT: - Receive 101 litres (exceeds the capacity) + POST: - ValidationError is raised about location capacity being full + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + self.location_flowable_1.write({"flowable_capacity": 100}) + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-FULL-LOT") + picking = self._create_incoming_picking( + self.location_flowable_1, product, lot, 101 + ) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + picking.button_validate() + + msg_error = "Location capacity is full" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_reduce_capacity_below_occupied(self): + """ + Test that reducing a flowable location's capacity below the occupied + amount is blocked. + + PRE: - A flowable location with stock (capacity_occupied > 0) + ACT: - Try to reduce capacity below the occupied amount + POST: - ValidationError is raised because occupied >= new capacity + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-REDUCECAP-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 100) + + # ACT & ASSERT + with self.assertRaises(ValidationError): + self.location_flowable_1.write({"flowable_capacity": 50}) + + def test_change_uom_with_existing_stock(self): + """ + Test that changing flowable_uom_id when quants with a different UoM + exist at the location is blocked. + + PRE: - A flowable location with stock (quants in litres) + ACT: - Change flowable_uom_id to a different UoM + POST: - ValidationError about different unit of measure + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-UOM-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 50) + + # Create a new product with units UoM to make the change valid + # for the allowed products constraint, isolating _check_flowable_uom_id + product_unit = self.env["product.product"].create( + { + "name": "Ar Cylinder", + "type": "consu", + "is_storable": True, + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + "tracking": "lot", + } + ) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.location_flowable_1.write( + { + "flowable_uom_id": self.uom_unit.id, + "flowable_allowed_product_ids": [ + (6, 0, [product_unit.id]), + ], + } + ) + + msg_error = "You have stock movements with different unit of measure" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_convert_location_with_unmixed_products(self): + """ + Test that enabling flowable_storage on a location that already has + multiple positive quants of the same product is blocked. + + PRE: - A non-flowable location with two lots of the same product + ACT: - Try to enable flowable_storage + POST: - UserError about unmixed products + """ + # ARRANGE — add two lots via inventory adjustments + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot_1 = self._create_lot(product, "UNMIXED-LOT-1") + lot_2 = self._create_lot(product, "UNMIXED-LOT-2") + + self._create_inventory_adjustment(self.location_1, product, lot_1, 50) + self._create_inventory_adjustment(self.location_1, product, lot_2, 30) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.location_1.write( + { + "flowable_storage": True, + "flowable_capacity": 1000, + "flowable_uom_id": product.uom_id.id, + "flowable_allowed_product_ids": [(4, product.id)], + } + ) + + msg_error = ( + "You cannot convert this location into a flowable location" + " because there are unmixed products." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_reduce_capacity_below_occupied_via_config(self): + """ + Test that reducing a flowable location's capacity below the occupied + amount via configuration raises a specific error about capacity + vs occupied. + + PRE: - A flowable location with stock (occupied ~100 L) + ACT: - Try to reduce capacity to 50 (below occupied) + POST: - ValidationError "Capacity must be greater than capacity + occupied" is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-CAPCFG-LOT") + self._seed_flowable_location(self.location_flowable_1, product, lot, 100) + + # Verify occupied amount is set + self.location_flowable_1.invalidate_recordset() + self.assertGreater(self.location_flowable_1.flowable_capacity_occupied, 0) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.location_flowable_1.write({"flowable_capacity": 50}) + + msg_error = "Capacity must be greater than capacity occupied" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_convert_location_with_incompatible_uom_stock(self): + """ + Test that enabling flowable_storage on a location that has stock + with a UoM different from the specified flowable_uom_id is blocked. + + PRE: - A non-flowable location with stock of a product using + Litres as UoM + ACT: - Try to enable flowable_storage with flowable_uom_id set + to Units (different from the product's Litres) + POST: - UserError about products with different units of measure + """ + # ARRANGE — stock the non-flowable location with a Litres product + product_litre = self.env["product.product"].create( + { + "name": "Liquid Ar", + "type": "consu", + "is_storable": True, + "uom_id": self.uom_litre.id, + "uom_po_id": self.uom_litre.id, + "tracking": "lot", + } + ) + lot = self._create_lot(product_litre, "INCOMPAT-UOM-LOT") + self._create_inventory_adjustment(self.location_1, product_litre, lot, 50) + + # ACT & ASSERT — try to enable flowable with Units UoM + with self.assertRaises(UserError) as error: + self.location_1.write( + { + "flowable_storage": True, + "flowable_capacity": 1000, + "flowable_uom_id": self.uom_unit.id, + "flowable_allowed_product_ids": [(4, product_litre.id)], + } + ) + + msg_error = ( + "You cannot convert this location into a flowable location" + " because there are products with different units of measure." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_capacity_occupied_zero_for_non_flowable_location(self): + """ + Test that a non-flowable location always has + flowable_capacity_occupied = 0 even if it has stock. + + PRE: - A non-flowable location with stock + ACT: - Read flowable_capacity_occupied + POST: - Value is 0 + """ + # ARRANGE — add stock via inventory adjustment + product = self.location_flowable_1.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "NONFLO-LOT") + self._create_inventory_adjustment(self.location_1, product, lot, 200) + + # ACT & ASSERT + self.location_1.invalidate_recordset() + self.assertEqual(self.location_1.flowable_capacity_occupied, 0) diff --git a/stock_location_flowable/tests/test_stock_move.py b/stock_location_flowable/tests/test_stock_move.py new file mode 100644 index 000000000..ade90479a --- /dev/null +++ b/stock_location_flowable/tests/test_stock_move.py @@ -0,0 +1,49 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockMove(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_modify_in_progress_flowable_move_raises_error(self): + """ + Test that cancelling a flowable production with a picking triggers + a ValidationError from the state constraint, preventing the + cancellation of an in-progress mixing. + + When a user clicks "Cancel" on the MO, the cancel flow changes + the raw moves' state, and the computed state field triggers + the @api.constrains check which blocks it. + + PRE: - A flowable MO in to_close state with a picking + ACT: - Cancel the production (user action) + POST: - ValidationError is raised about mixing in progress + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production.picking_id) + self.assertEqual(production.state, "to_close") + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + production.action_cancel() + + msg_error = ( + "You cannot cancel a production with a picking associated." + " The mixing is in progress." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_move_line.py b/stock_location_flowable/tests/test_stock_move_line.py new file mode 100644 index 000000000..6e1a1a09c --- /dev/null +++ b/stock_location_flowable/tests/test_stock_move_line.py @@ -0,0 +1,126 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockMoveLine(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_blocked_location_rejects_unrelated_production_done(self): + """ + Test that validating a picking whose destination is a blocked + flowable location raises an error. + + PRE: - A flowable location blocked by a production + ACT: - Try to validate an outgoing picking targeting that location + POST: - An error is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.incoming_picking.button_validate() + + # Location should now be blocked by the flowable production + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(Exception) as error: + self.outgoing_picking.button_validate() + + msg_error = ( + "The location %(location)s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_blocked_location_rejects_incoming_reception(self): + """ + Test that a second PO reception to a blocked flowable location + is rejected even though the reception is not part of any MO. + + PRE: - A flowable location blocked by a production (from a first reception) + - A second incoming picking prepared before the location was blocked + ACT: - Try to validate the second incoming picking + POST: - A ValidationError is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_2 = self._create_lot(self.product_flowable_1, "Lot2") + second_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 10 + ) + + # Block the location by validating the first reception + self.incoming_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + second_picking.button_validate() + + msg_error = ( + "The location %(location)s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_blocked_location_rejects_internal_transfer(self): + """ + Test that an internal transfer from a blocked flowable location + is rejected. + + PRE: - A flowable location blocked by a production + - An internal transfer prepared before the location was blocked + ACT: - Try to validate the internal transfer + POST: - An error is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_2 = self._create_lot(self.product_flowable_1, "LotInternal") + transfer_picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_internal_1.id, + "location_id": self.location_flowable_1.id, + "location_dest_id": self.location_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": transfer_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_2.id, + "quantity": 5, + "location_id": self.location_flowable_1.id, + "location_dest_id": self.location_1.id, + "company_id": self.env.company.id, + } + ) + + # Block the location by validating the first reception + self.incoming_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(Exception) as error: + transfer_picking.button_validate() + + msg_error = ( + "The location %(location)s is blocked. Probably you need to review" + " the pending manufacturing orders related to this location" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_picking.py b/stock_location_flowable/tests/test_stock_picking.py new file mode 100644 index 000000000..f1cf3d6bf --- /dev/null +++ b/stock_location_flowable/tests/test_stock_picking.py @@ -0,0 +1,1326 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL- Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockPicking(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.incoming_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_incoming_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.outgoing_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_outgoing_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_1.id, + } + ) + + cls.internal_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_internal_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + cls.mrp_picking = cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type_mrp_operation_1.id, + "location_id": cls.location_flowable_1.id, + "location_dest_id": cls.location_flowable_2.id, + } + ) + + def test_receiving_one_product_in_flowable_location_incoming_picking(self): + """ + Test to ensure that receiving more than one product in a flowable location + raises an error. + + PRE: - A picking with multiple lines directed to a flowable location + ACT: - Try to validate the picking + POST: - UserError is raised stating that only one product can be received + at a flowable location + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + moves1 = self.env["stock.move"].create( + { + "name": self.product_flowable_1.name, + "product_id": self.product_flowable_1.id, + "product_uom_qty": 5, + "product_uom": self.product_flowable_1.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + moves2 = self.env["stock.move"].create( + { + "name": self.product_flowable_2.name, + "product_id": self.product_flowable_2.id, + "product_uom_qty": 10, + "product_uom": self.product_flowable_2.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_1 = self._create_lot(self.product_flowable_1, "TEST LMP-0001") + + lot_ch4 = self._create_lot(self.product_flowable_2, "TEST LMP-0002") + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves1.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 5, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.move_line_ids |= self.env["stock.move.line"].create( + { + "move_id": moves2.id, + "product_id": self.product_flowable_2.id, + "product_uom_id": self.product_flowable_2.uom_id.id, + "lot_id": lot_ch4.id, + "quantity": 10, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = ( + "Cannot receive multiple product/lot combinations" + " (%(details)s) at flowable location '%(location)s'" + " in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_same_product_different_lots_same_location_rejected(self): + """ + Test that receiving the same product with different lots at + the same flowable location in one receipt is rejected. + + PRE: - A picking with 2 move lines of the same product but + different lots, both targeting the same flowable location + ACT: - Try to validate the picking + POST: - UserError is raised about multiple product/lot combinations + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "TEST-DIFFLOT-A") + lot_b = self._create_lot(self.product_flowable_1, "TEST-DIFFLOT-B") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + for lot, qty in [(lot_a, 10), (lot_b, 20)]: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot.id, + "quantity": qty, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + picking.button_validate() + + msg_error = ( + "Cannot receive multiple product/lot combinations" + " (%(details)s) at flowable location '%(location)s'" + " in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_only_allowed_product_in_incoming_picking(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + product_bolts = self.env["product.product"].create( + { + "name": "Steel Bolts", + "type": "consu", + "is_storable": True, + "uom_id": self.env.ref("uom.product_uom_unit").id, + "uom_po_id": self.env.ref("uom.product_uom_unit").id, + "tracking": "lot", + } + ) + + moves1 = self.env["stock.move"].create( + { + "name": product_bolts.name, + "product_id": product_bolts.id, + "product_uom_qty": 5, + "product_uom": product_bolts.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_bolts = self._create_lot(product_bolts, "TEST COD-0001") + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves1.id, + "product_id": product_bolts.id, + "product_uom_id": product_bolts.uom_id.id, + "lot_id": lot_bolts.id, + "quantity": 5, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = ( + "Product %(product)s not allowed" " in flowable location %(location)s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_different_uom_allowed_product_in_incoming_picking(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + product_he = self.env["product.product"].create( + { + "name": "Liquid He", + "type": "consu", + "is_storable": True, + "uom_id": self.env.ref("uom.product_uom_litre").id, + "uom_po_id": self.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + + self.location_flowable_1.flowable_allowed_product_ids = [(4, product_he.id)] + + product_he.write( + { + "uom_id": self.env.ref("uom.product_uom_unit").id, + "uom_po_id": self.env.ref("uom.product_uom_unit").id, + } + ) + + moves1 = self.env["stock.move"].create( + { + "name": product_he.name, + "product_id": product_he.id, + "product_uom_qty": 5, + "product_uom": product_he.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_he = self._create_lot(product_he, "TEST COD-0001") + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves1.id, + "product_id": product_he.id, + "product_uom_id": product_he.uom_id.id, + "lot_id": lot_he.id, + "quantity": 5, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = ( + "The allowed products %(product)s cannot have" + " different Unit of Measure than" + " flowable location %(location)s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_not_found_manufacturing_picking_type_incoming_picking(self): + # ARRANGE + moves = self.env["stock.move"].create( + { + "name": self.product_flowable_1.name, + "product_id": self.product_flowable_1.id, + "product_uom_qty": 10, + "product_uom": self.product_flowable_1.uom_id.id, + "picking_id": self.incoming_picking.id, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_confirm() + + lot_1 = self._create_lot(self.product_flowable_1, "TEST LMP-0001") + + self.incoming_picking.move_line_ids = self.env["stock.move.line"].create( + { + "move_id": moves.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": self.incoming_picking.location_id.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + } + ) + + self.incoming_picking.action_assign() + + # ACT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + # ASSERT + msg_error = ( + "Not found flowable manufacturing picking type" + " in warehouse %(warehouse)s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_more_than_one_manufacturing_picking_type_incoming_picking(self): + """ + Test that having more than one flowable manufacturing picking type + in the same warehouse raises an error during picking validation. + + PRE: - Two flowable mrp_operation picking types in the same warehouse + (second one created bypassing the ORM constraint via SQL) + ACT: - Validate a picking to a flowable location + POST: - UserError is raised about duplicate picking types + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + picking_type_mrp_operation_2 = self.env["stock.picking.type"].create( + { + "name": "Production2", + "sequence_code": "SEQ-MRP2", + "code": "mrp_operation", + "warehouse_id": self.picking_type_mrp_operation_1.warehouse_id.id, + } + ) + # Bypass the ORM constraint to simulate data inconsistency + self.env.cr.execute( + "UPDATE stock_picking_type SET flowable_operation = TRUE WHERE id = %s", + (picking_type_mrp_operation_2.id,), + ) + picking_type_mrp_operation_2.invalidate_recordset() + + lot_1 = self._create_lot(self.product_flowable_1, "TEST-DUP-LOT") + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": self.supplier_location.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + msg_error = ( + "More than one flowable manufacturing picking type" + " in warehouse %(warehouse)s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_successfull_picking_type_incoming_picking(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "TEST LMP-0001") + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": self.supplier_location.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + self.incoming_picking.action_confirm() + + # ACT & ASSERT + self.incoming_picking.button_validate() + + def test_action_view_mrp_production_single(self): + """ + Test that action_view_mrp_production returns a form view when there + is exactly one production linked to the picking. + + PRE: - A picking with one flowable production + ACT: - Call action_view_mrp_production + POST: - Action opens the form view with the production res_id + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "TEST-ACTION-LOT") + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": self.supplier_location.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + self.incoming_picking.button_validate() + + # ACT + action = self.incoming_picking.action_view_mrp_production() + + # ASSERT + self.assertEqual(action["res_model"], "mrp.production") + self.assertEqual( + action["res_id"], + self.incoming_picking.flowable_production_ids[0].id, + ) + + def test_action_view_mrp_production_multiple(self): + """ + Test that action_view_mrp_production returns a list view when there + are multiple productions linked to the picking. + + PRE: - A picking with multiple flowable productions + ACT: - Call action_view_mrp_production + POST: - Action opens a list filtered by production ids + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "TEST-MULTI-LOT") + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": self.supplier_location.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + self.incoming_picking.button_validate() + + # Create a second production manually linked to the same picking + self.env["mrp.production"].create( + { + "product_id": self.product_flowable_1.id, + "product_qty": 5, + "product_uom_id": self.product_flowable_1.uom_id.id, + "picking_type_id": self.picking_type_mrp_operation_1.id, + "location_src_id": self.location_flowable_1.id, + "location_dest_id": self.location_flowable_1.id, + "picking_id": self.incoming_picking.id, + } + ) + + # ACT + action = self.incoming_picking.action_view_mrp_production() + + # ASSERT + self.assertIn("domain", action) + self.assertEqual( + action["domain"], + [("id", "in", self.incoming_picking.flowable_production_ids.ids)], + ) + + def test_mrp_operation_type_without_sequence_raises_error(self): + """ + Test that a flowable mrp_operation picking type without a sequence + raises an error during picking validation. + + PRE: - A flowable mrp_operation picking type without sequence_id + ACT: - Validate a picking to a flowable location + POST: - UserError is raised about missing sequence + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.picking_type_mrp_operation_1.sequence_id = False + + lot_1 = self._create_lot(self.product_flowable_1, "TEST-NOSEQ-LOT") + + self.env["stock.move.line"].create( + { + "picking_id": self.incoming_picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_1.id, + "quantity": 10, + "location_id": self.supplier_location.id, + "location_dest_id": self.incoming_picking.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + self.incoming_picking.button_validate() + + msg_error = ( + "Not found sequence in flowable manufacturing" + " picking type %(picking_type)s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_non_lot_tracked_product_at_flowable_location(self): + """ + Test that receiving a product whose tracking was changed from 'lot' + to 'none' after being added to the flowable allowed products raises + an error during picking validation. + + PRE: - A product added to flowable allowed products with tracking=lot + - Product tracking changed to 'none' afterwards + ACT: - Validate an incoming picking with that product + POST: - UserError about product tracking + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + product_nolot = self.env["product.product"].create( + { + "name": "Liquid H2", + "type": "consu", + "is_storable": True, + "uom_id": self.env.ref("uom.product_uom_litre").id, + "uom_po_id": self.env.ref("uom.product_uom_litre").id, + "tracking": "lot", + } + ) + self.location_flowable_1.write( + {"flowable_allowed_product_ids": [(4, product_nolot.id)]} + ) + # Change tracking after adding to allowed products + # (bypasses location constraint) + product_nolot.tracking = "none" + + lot = self._create_lot(product_nolot, "TEST-NOLOT") + picking = self._create_incoming_picking( + self.location_flowable_1, product_nolot, lot, 10 + ) + + # ACT & ASSERT + with self.assertRaises(UserError) as error: + picking.button_validate() + + msg_error = "Product %(product)s must be tracked by lot" + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_auto_lot_creation_with_sequence(self): + """ + Test that receiving stock at a flowable location with + flowable_create_lots=True auto-creates a lot from the sequence. + + PRE: - location_flowable_2 has flowable_create_lots=True and + flowable_sequence_id set + ACT: - Validate an incoming picking to location_flowable_2 + POST: - The auto-created production has a lot_producing_id + different from the incoming lot + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_2.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-AUTOLOT") + picking = self._create_incoming_picking( + self.location_flowable_2, product, lot, 50 + ) + + # ACT + picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_2) + + # ASSERT + self.assertTrue(production.lot_producing_id) + self.assertNotEqual(production.lot_producing_id, lot) + + def test_reception_blocks_flowable_location(self): + """ + Test that validating a reception to a flowable location blocks it + and creates a manufacturing order linked to the location. + + PRE: - A flowable location with no active production + ACT: - Validate an incoming picking to that location + POST: - The location is blocked (flowable_blocked is True) + - A manufacturing order is linked to the location + - The MO is linked back to the picking + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + self.assertFalse(self.location_flowable_1.flowable_blocked) + + lot = self._create_lot(self.product_flowable_1, "TEST-BLOCK-LOT") + picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot, 10 + ) + + # ACT + picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + production = self.location_flowable_1.flowable_production_id + self.assertTrue(production) + self.assertEqual(production.picking_id, picking) + + def test_second_reception_to_blocked_location_rejected(self): + """ + Test that a second reception to a blocked flowable location + is rejected. + + PRE: - A flowable location blocked by a first reception + - A second incoming picking prepared before the location + was blocked + ACT: - Try to validate the second incoming picking + POST: - An error is raised about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "TEST-BLOCK-LOT1") + first_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 10 + ) + + lot_2 = self._create_lot(self.product_flowable_1, "TEST-BLOCK-LOT2") + second_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 10 + ) + + first_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT + with self.assertRaises(UserError): + second_picking.button_validate() + + def test_reception_after_mo_completed_succeeds(self): + """ + Test the full cycle: reception blocks the location, completing + the MO unblocks it, and a new reception succeeds. + + PRE: - A flowable location with no active production + ACT: - Validate a first reception (location gets blocked) + - Complete the resulting MO (location gets unblocked) + - Validate a second reception + POST: - The location is unblocked after completing the MO + - The second reception succeeds and creates a new MO + - The location is blocked again by the new MO + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "TEST-CYCLE-LOT1") + first_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 10 + ) + + # ACT 1 - First reception blocks the location + first_picking.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + first_production = self.location_flowable_1.flowable_production_id + + # ACT 2 - Complete the MO, location gets unblocked + first_production.button_mark_done() + self.assertFalse(self.location_flowable_1.flowable_blocked) + + # ACT 3 - Second reception succeeds and blocks again + lot_2 = self._create_lot(self.product_flowable_1, "TEST-CYCLE-LOT2") + second_picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 5 + ) + second_picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + second_production = self.location_flowable_1.flowable_production_id + self.assertTrue(second_production) + self.assertNotEqual(first_production, second_production) + self.assertEqual(second_production.picking_id, second_picking) + + def test_blocking_is_per_location(self): + """ + Test that blocking one flowable location does not affect another. + + PRE: - Two flowable locations with no active production + ACT: - Validate a reception to location 1 (blocks it) + - Validate a reception to location 2 + POST: - Location 1 is blocked + - Location 2 reception succeeds and blocks location 2 + independently + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "TEST-PERLOC-LOT1") + picking_1 = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 10 + ) + + lot_2 = self._create_lot(self.product_flowable_1, "TEST-PERLOC-LOT2") + picking_2 = self._create_incoming_picking( + self.location_flowable_2, self.product_flowable_1, lot_2, 10 + ) + + # ACT + picking_1.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertFalse(self.location_flowable_2.flowable_blocked) + + picking_2.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertTrue(self.location_flowable_2.flowable_blocked) + self.assertNotEqual( + self.location_flowable_1.flowable_production_id, + self.location_flowable_2.flowable_production_id, + ) + + def test_auto_lot_location_also_gets_blocked(self): + """ + Test that a flowable location with flowable_create_lots=True + also gets blocked after a reception. + + PRE: - location_flowable_2 has flowable_create_lots=True + ACT: - Validate a reception to location_flowable_2 + POST: - The location is blocked + - The MO uses an auto-generated lot (different from the + incoming lot) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + product = self.location_flowable_2.flowable_allowed_product_ids[0] + + lot = self._create_lot(product, "TEST-AUTOLOT-BLOCK") + picking = self._create_incoming_picking( + self.location_flowable_2, product, lot, 50 + ) + + # ACT + picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_2.flowable_blocked) + production = self.location_flowable_2.flowable_production_id + self.assertTrue(production) + self.assertNotEqual(production.lot_producing_id, lot) + + def test_non_flowable_location_not_affected(self): + """ + Test that receiving stock at a non-flowable location does not + trigger any blocking or MO creation. + + PRE: - A regular (non-flowable) internal location + ACT: - Validate an incoming picking to that location + POST: - No flowable_production_id is set + - No MO is created for the picking + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + product = self.product_flowable_1 + lot = self._create_lot(product, "TEST-NONFLOW-LOT") + picking = self._create_incoming_picking(self.location_1, product, lot, 10) + + # ACT + picking.button_validate() + + # ASSERT + self.assertFalse(self.location_1.flowable_storage) + self.assertFalse(picking.flowable_production_ids) + + def test_mixing_reception_with_rounding_residual_rejected(self): + """ + Test that receiving stock at a flowable location is rejected when + there are 3 positive quants instead of the expected 2 for a mixing + scenario. + + This reproduces the rounding residual scenario (like the MO 00310 + incident): a tiny residual from a previous UoM rounding issue + creates an extra quant, making the location state inconsistent. + + PRE: - Flowable location with lot A (100 L) from completed MO + - Inventory adjustment adds 0.01 L of lot B (rounding + residual) + - Location now has 2 quants: A (100 L) + B (0.01 L) + ACT: - Receive lot C (50 L) at the flowable location + POST: - After _action_done, there are 3 positive quants + - UserError "expected 2 positive quants but found 3" + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # Seed the flowable location with lot A + lot_a = self._create_lot(self.product_flowable_1, "RESIDUAL-LOT-A") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_a, 100 + ) + + # Add a rounding residual via inventory adjustment (lot B, 0.01 L) + lot_b = self._create_lot(self.product_flowable_1, "RESIDUAL-LOT-B") + self._create_inventory_adjustment( + self.location_flowable_1, self.product_flowable_1, lot_b, 0.01 + ) + + # Verify we have 2 positive quants now + quants = self._get_location_quants( + self.location_flowable_1, self.product_flowable_1 + ) + positive_quants = quants.filtered(lambda q: q.quantity > 0) + self.assertEqual( + len(positive_quants), 2, "Should have 2 quants (lot A + residual lot B)" + ) + + # ACT — receive lot C, which creates a 3rd quant + lot_c = self._create_lot(self.product_flowable_1, "RESIDUAL-LOT-C") + with self.assertRaises(UserError) as error: + self._receive_stock( + self.location_flowable_1, self.product_flowable_1, lot_c, 50 + ) + + # ASSERT + msg_error = "expected 2 positive quants but found 3" + self.assertIn(msg_error, str(error.exception)) + + def test_multiple_lines_same_product_aggregated_into_one_mo(self): + """ + Test that multiple move lines with the same product, lot, and + destination are aggregated into a single manufacturing order. + + This reproduces the scenario where a PO has N lines of the same + product: each line creates a separate stock move on the picking, + but when all move lines target the same flowable tank with the same + lot, the flowable code must merge them into one MO. + + PRE: - A flowable location seeded with 100 L of lot A (MO completed) + - An incoming picking with 3 move lines of the same product, + same lot B, same destination (simulating 3 PO lines) + ACT: - Validate the picking + POST: - Only 1 MO is created (not 3) + - The MO has 2 raw move lines: one for lot A (existing stock) + and one for lot B (sum of the 3 reception lines) + - The MO quantity equals existing stock + total received + - The location is blocked by that single MO + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # Seed the tank with 100 L of lot A and complete the initial MO + lot_a = self._create_lot(self.product_flowable_1, "TEST-AGGR-LOT-A") + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_a, 100 + ) + self.assertFalse(self.location_flowable_1.flowable_blocked) + + # Create a picking with 3 move lines of the same product, lot B, same tank + lot_b = self._create_lot(self.product_flowable_1, "TEST-AGGR-LOT-B") + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + + line_qtys = [10, 20, 30] + for qty in line_qtys: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_b.id, + "quantity": qty, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT — only 1 MO created + productions = picking.flowable_production_ids + self.assertEqual( + len(productions), + 1, + "Multiple lines of the same product/lot/dest should produce exactly 1 MO", + ) + + # The MO quantity should equal existing stock + total received + total_received = sum(line_qtys) + self.assertEqual(productions.product_qty, 100 + total_received) + + # The MO raw move should have exactly 2 move lines: + # one for lot A (existing 100 L) and one for lot B (received 60 L) + raw_move_lines = productions.move_raw_ids.move_line_ids + self.assertEqual( + len(raw_move_lines), + 2, + "MO should have 2 raw move lines: existing lot + received lot", + ) + lot_a_line = raw_move_lines.filtered(lambda ml: ml.lot_id == lot_a) + lot_b_line = raw_move_lines.filtered(lambda ml: ml.lot_id == lot_b) + self.assertEqual(len(lot_a_line), 1) + self.assertEqual(len(lot_b_line), 1) + self.assertEqual(lot_a_line.quantity, 100) + self.assertEqual(lot_b_line.quantity, total_received) + + # The location should be blocked by that MO + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertEqual(self.location_flowable_1.flowable_production_id, productions) + + def test_backorder_cascade_three_lots(self): + """ + Test the backorder cascade: 3 lots to the same flowable location, + processed one at a time via backorders. + + PRE: - 3 sequential receptions each with 1 lot, where each creates + a backorder for the remaining + ACT: - Receive lot 0, validate → MO → complete → unblocked + - Receive lot 1, validate → MO → complete → unblocked + - Receive lot 2, validate → MO → complete → unblocked + POST: - 3 MOs created total, all completed, location unblocked at end + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lots = [ + self._create_lot(self.product_flowable_1, f"CASCADE-LOT-{i}") + for i in range(3) + ] + qtys = [100, 200, 150] + + completed_productions = self.env["mrp.production"] + + for step, (lot, qty) in enumerate(zip(lots, qtys, strict=False)): + picking = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot, qty + ) + picking.button_validate() + + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production, f"MO should be created at step {step}") + production.button_mark_done() + completed_productions |= production + + # ASSERT + self.assertEqual(len(completed_productions), 3) + self.assertTrue(all(p.state == "done" for p in completed_productions)) + self.assertFalse(self.location_flowable_1.flowable_blocked) + + def test_backorder_remaining_lines_still_rejected(self): + """ + Test that a backorder with 2 remaining flowable lines to the same + location is rejected by the pre-check. + + PRE: - A picking with 2 move lines of different lots, same product, + same flowable location + - First line validated (creates backorder with 1 remaining) + and MO completed + ACT: - Add a second line to the backorder and try to validate both + POST: - UserError about multiple product/lot combinations + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_0 = self._create_lot(self.product_flowable_1, "BKORD-LOT-0") + lot_1 = self._create_lot(self.product_flowable_1, "BKORD-LOT-1") + lot_2 = self._create_lot(self.product_flowable_1, "BKORD-LOT-2") + + # First reception — seed the location + self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot_0, 100 + ) + self.assertFalse(self.location_flowable_1.flowable_blocked) + + # Create a picking with 2 move lines of different lots + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + for lot, qty in [(lot_1, 200), (lot_2, 150)]: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot.id, + "quantity": qty, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT & ASSERT — pre-check rejects both lines at the same location + with self.assertRaises(UserError) as error: + picking.button_validate() + + msg_error = ( + "Cannot receive multiple product/lot combinations" + " (%(details)s) at flowable location '%(location)s'" + " in the same" + " receipt. Each combination generates a separate" + " mixing order and the location is blocked after" + " the first one. Create a backorder to receive" + " them in separate steps." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_backorder_validation_while_location_blocked(self): + """ + Test that validating a second reception while the location is still + blocked by an in-progress MO is rejected. + + Simulates 2 deliveries arriving at the warehouse: both pickings are + prepared before the first is validated. After validating the first + (which blocks the location), validating the second should fail. + + PRE: - Two incoming pickings prepared for the same flowable location + ACT: - Validate the first picking (blocks the location) + - Try to validate the second picking + POST: - Error about the location being blocked + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_1 = self._create_lot(self.product_flowable_1, "BLOCKED-BO-LOT-1") + lot_2 = self._create_lot(self.product_flowable_1, "BLOCKED-BO-LOT-2") + + # Both pickings are prepared before any validation + picking_1 = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_1, 100 + ) + picking_2 = self._create_incoming_picking( + self.location_flowable_1, self.product_flowable_1, lot_2, 200 + ) + + # First reception — blocks the location + picking_1.button_validate() + self.assertTrue(self.location_flowable_1.flowable_blocked) + + # ACT & ASSERT — second reception rejected while location is blocked + with self.assertRaises(UserError): + picking_2.button_validate() + + def test_mixed_flowable_and_non_flowable_lines(self): + """ + Test that a picking with both flowable and non-flowable destination + lines validates correctly: the flowable line creates an MO, the + non-flowable line goes through normally. + + PRE: - A picking with one line to a flowable location and another + line to a non-flowable location + ACT: - Validate the picking + POST: - The flowable location is blocked with an MO + - The non-flowable location has stock (no MO) + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_flow = self._create_lot(self.product_flowable_1, "MIXED-FLOW-LOT") + lot_nonflow = self._create_lot(self.product_flowable_1, "MIXED-NONFLOW-LOT") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 1 → flowable location + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_flow.id, + "quantity": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + # Line 2 → non-flowable location + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_nonflow.id, + "quantity": 30, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT + self.assertTrue(self.location_flowable_1.flowable_blocked) + production = self._find_flowable_production(self.location_flowable_1) + self.assertTrue(production) + + # Non-flowable location has stock, no MO + nonflow_quant = self.env["stock.quant"].search( + [ + ("location_id", "=", self.location_1.id), + ("product_id", "=", self.product_flowable_1.id), + ("lot_id", "=", lot_nonflow.id), + ] + ) + self.assertEqual(nonflow_quant.quantity, 30) + + def test_same_product_different_lots_different_locations_succeeds(self): + """ + Test that receiving the same product with different lots at different + flowable locations in one receipt succeeds (no conflict). + + PRE: - A picking with 2 lines: lot A → location_flowable_1, + lot B → location_flowable_2 + ACT: - Validate the picking + POST: - Both locations are blocked with separate MOs + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "DIFFLOC-LOT-A") + lot_b = self._create_lot(self.product_flowable_1, "DIFFLOC-LOT-B") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 1 → location_flowable_1 + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_a.id, + "quantity": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + # Line 2 → location_flowable_2 + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_b.id, + "quantity": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_2.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT — both locations are blocked independently + self.assertTrue(self.location_flowable_1.flowable_blocked) + self.assertTrue(self.location_flowable_2.flowable_blocked) + self.assertNotEqual( + self.location_flowable_1.flowable_production_id, + self.location_flowable_2.flowable_production_id, + ) + + def test_zero_qty_done_flowable_lines_ignored(self): + """ + Test that move lines with quantity=0 at a flowable location are + ignored and don't create MOs or trigger conflicts. + + PRE: - A picking with 2 lines to a flowable location: one with + quantity=50 and another with quantity=0 + ACT: - Validate the picking + POST: - Only 1 MO is created (for the non-zero line) + - No pre-check error about multiple combinations + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "ZERO-QTY-LOT-A") + lot_b = self._create_lot(self.product_flowable_1, "ZERO-QTY-LOT-B") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_a.id, + "quantity": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot_b.id, + "quantity": 0, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + "company_id": self.env.company.id, + } + ) + + # ACT + picking.button_validate() + + # ASSERT + productions = picking.flowable_production_ids + self.assertEqual(len(productions), 1) + self.assertTrue(self.location_flowable_1.flowable_blocked) + + def test_create_lots_true_topup_same_incoming_lot(self): + """ + Test top-up at a create_lots=True location where the incoming lot + is the same as the existing lot. The auto-generated producing lot + should still be different from the incoming lot. + + PRE: - Flowable location (create_lots=True) seeded with lot A + ACT: - Receive lot A again (top-up with same lot) + POST: - MO is created with an auto-generated producing lot + - The producing lot is different from lot A + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_a = self._create_lot(self.product_flowable_1, "TOPUP-SAME-LOT") + self._seed_flowable_location( + self.location_flowable_2, self.product_flowable_1, lot_a, 100 + ) + + # ACT — receive lot A again at the same location + self._receive_stock( + self.location_flowable_2, self.product_flowable_1, lot_a, 50 + ) + + # ASSERT + production = self._find_flowable_production(self.location_flowable_2) + self.assertTrue(production) + self.assertNotEqual( + production.lot_producing_id, + lot_a, + "Auto-generated lot should differ from the incoming lot", + ) diff --git a/stock_location_flowable/tests/test_stock_picking_type.py b/stock_location_flowable/tests/test_stock_picking_type.py new file mode 100644 index 000000000..881b2b74e --- /dev/null +++ b/stock_location_flowable/tests/test_stock_picking_type.py @@ -0,0 +1,57 @@ +# Copyright NuoBiT Solutions - Frank Cespedes +# Copyright 2026 NuoBiT Solutions - Eric Antones +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockPickingType(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_unique_flowable_operation_picking_type(self): + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + # ACT + with self.assertRaises(ValidationError) as error: + self.picking_type_mrp_operation_1_2 = self.env["stock.picking.type"].create( + { + "name": "Production2 2", + "sequence_code": "SEQ-MRP 2", + "code": "mrp_operation", + "flowable_operation": True, + } + ) + + # ASSERT + msg_error = ( + "Only one picking type can be flowable" + " in a warehouse %(warehouse_name)s." + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_non_mrp_picking_type_cannot_be_flowable(self): + """ + Test that setting flowable_operation on a non-mrp_operation picking type + raises a ValidationError. + + PRE: - An incoming picking type + ACT: - Set flowable_operation to True + POST: - ValidationError is raised + """ + # ACT & ASSERT + with self.assertRaises(ValidationError) as error: + self.picking_type_incoming_1.flowable_operation = True + + msg_error = "Only manufacturing picking types can be flowable." + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_quant.py b/stock_location_flowable/tests/test_stock_quant.py new file mode 100644 index 000000000..242118016 --- /dev/null +++ b/stock_location_flowable/tests/test_stock_quant.py @@ -0,0 +1,53 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2026 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import ValidationError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockQuant(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_unique_lot_constraint_at_flowable_location(self): + """ + Test that having more than one positive lot quant at a flowable location + raises a ValidationError. + + A user who does two successive inventory adjustments at the same + flowable location with different lots should be blocked on the second. + + PRE: - A flowable location with stock from a first lot (via inventory) + ACT: - Perform a second inventory adjustment adding a different lot + POST: - ValidationError is raised about duplicate lots + """ + # ARRANGE — first lot via inventory adjustment + lot_1 = self._create_lot(self.product_flowable_1, "QUANT-LOT-1") + self._create_inventory_adjustment( + self.location_flowable_1, self.product_flowable_1, lot_1, 100 + ) + + # ACT — second lot via inventory adjustment + lot_2 = self._create_lot(self.product_flowable_1, "QUANT-LOT-2") + + # ASSERT + with self.assertRaises(ValidationError) as error: + self.env["stock.quant"].with_context(inventory_mode=True).create( + { + "product_id": self.product_flowable_1.id, + "location_id": self.location_flowable_1.id, + "lot_id": lot_2.id, + "inventory_quantity": 50, + } + ).action_apply_inventory() + + msg_error = "You cannot have more than one lot in the same location." + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/tests/test_stock_return_picking.py b/stock_location_flowable/tests/test_stock_return_picking.py new file mode 100644 index 000000000..6a56c76c6 --- /dev/null +++ b/stock_location_flowable/tests/test_stock_return_picking.py @@ -0,0 +1,225 @@ +# Copyright 2026 NuoBiT Solutions - Eric Antones +# Copyright 2025 NuoBiT Solutions SL - Deniz Gallo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo.exceptions import UserError + +from .test_common import TestCommon + +_logger = logging.getLogger(__name__) + + +class TestStockReturnPicking(TestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + def test_return_from_flowable_location_raises_error(self): + """ + Test that returning a product that was delivered to a flowable + location is blocked. + + PRE: - A completed incoming picking to a flowable location + ACT: - Attempt to create a return for that picking + POST: - UserError is raised + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot = self._create_lot(self.product_flowable_1, "TEST-RETURN-LOT") + picking = self._seed_flowable_location( + self.location_flowable_1, self.product_flowable_1, lot, 50 + ) + + # ACT + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_id=picking.id, + active_model="stock.picking", + ) + .create( + { + "picking_id": picking.id, + } + ) + ) + + with self.assertRaises(UserError) as error: + return_wizard._create_return() + + # ASSERT + msg_error = ( + "You cannot return the following products because" + " they come from a flowable location: %s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) + + def test_return_from_non_flowable_location_succeeds(self): + """ + Test that returning a product that was delivered to a non-flowable + location is allowed. + + PRE: - A completed incoming picking to a non-flowable location + ACT: - Create a return for that picking + POST: - Return picking is created successfully + """ + # ARRANGE + lot = self._create_lot(self.product_flowable_1, "TEST-RETURN-NOFLO") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_1.id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.product_flowable_1.name, + "product_id": self.product_flowable_1.id, + "product_uom_qty": 50, + "product_uom": self.product_flowable_1.uom_id.id, + "picking_id": picking.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_1.id, + } + ) + picking.action_confirm() + picking.action_assign() + move.move_line_ids.write( + { + "quantity": 50, + "lot_id": lot.id, + } + ) + picking.button_validate() + + # ACT + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_id=picking.id, + active_model="stock.picking", + ) + .create( + { + "picking_id": picking.id, + } + ) + ) + return_wizard.product_return_moves.quantity = 50 + new_picking = return_wizard._create_return() + + # ASSERT + self.assertTrue(new_picking) + self.assertEqual(new_picking.state, "assigned") + + def test_return_from_mixed_flowable_non_flowable_picking(self): + """ + Test that returning from a picking that delivered to both a flowable + and a non-flowable location only blocks the return of the flowable + product. + + PRE: - A completed incoming picking with: + - Line 1: product to flowable location (MO completed) + - Line 2: product to non-flowable location + ACT: - Attempt to return the flowable product + POST: - UserError about the flowable product + """ + # ARRANGE + self.picking_type_mrp_operation_1.flowable_operation = True + + lot_flow = self._create_lot(self.product_flowable_1, "RET-MIXED-FLOW") + lot_nonflow = self._create_lot(self.product_flowable_1, "RET-MIXED-NOFLOW") + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.picking_type_incoming_1.id, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 1 → flowable location + move_flow = self.env["stock.move"].create( + { + "name": "Flowable line", + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom": self.product_flowable_1.uom_id.id, + "product_uom_qty": 50, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_flowable_1.id, + } + ) + # Line 2 → non-flowable location + move_nonflow = self.env["stock.move"].create( + { + "name": "Non-flowable line", + "picking_id": picking.id, + "product_id": self.product_flowable_1.id, + "product_uom": self.product_flowable_1.uom_id.id, + "product_uom_qty": 30, + "location_id": self.supplier_location.id, + "location_dest_id": self.location_1.id, + } + ) + picking.action_confirm() + picking.action_assign() + + for move, lot, qty in [ + (move_flow, lot_flow, 50), + (move_nonflow, lot_nonflow, 30), + ]: + ml = move.move_line_ids + if ml: + ml.write({"lot_id": lot.id, "quantity": qty}) + else: + self.env["stock.move.line"].create( + { + "picking_id": picking.id, + "move_id": move.id, + "product_id": self.product_flowable_1.id, + "product_uom_id": self.product_flowable_1.uom_id.id, + "lot_id": lot.id, + "quantity": qty, + "location_id": self.supplier_location.id, + "location_dest_id": move.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + + picking.button_validate() + + # Complete the MO so the location is unblocked + production = self._find_flowable_production(self.location_flowable_1) + if production: + production.button_mark_done() + + # ACT — try to return both products + return_wizard = ( + self.env["stock.return.picking"] + .with_context( + active_id=picking.id, + active_model="stock.picking", + ) + .create( + { + "picking_id": picking.id, + } + ) + ) + + # ASSERT — error mentions the flowable product + with self.assertRaises(UserError) as error: + return_wizard._create_return() + + msg_error = ( + "You cannot return the following products because" + " they come from a flowable location: %s" + ) + msg_error = self.get_error_message_regex(msg_error) + self.assertRegex(error.exception.args[0], msg_error) diff --git a/stock_location_flowable/views/mrp_production_views.xml b/stock_location_flowable/views/mrp_production_views.xml new file mode 100644 index 000000000..7de430110 --- /dev/null +++ b/stock_location_flowable/views/mrp_production_views.xml @@ -0,0 +1,31 @@ + + + + + mrp.production.form.inherit + mrp.production + + + + + + + production_blocked + + + + + + production_flowable + + + production_flowable + + + + diff --git a/stock_location_flowable/views/stock_location_views.xml b/stock_location_flowable/views/stock_location_views.xml new file mode 100644 index 000000000..19365216b --- /dev/null +++ b/stock_location_flowable/views/stock_location_views.xml @@ -0,0 +1,120 @@ + + + + + stock.location.form.inherit + stock.location + + + + + + + +