From c7072eaba6c575c84817c48bc4f41010f3f8972c Mon Sep 17 00:00:00 2001 From: Adrien Widart Date: Tue, 3 May 2022 08:41:13 +0000 Subject: [PATCH] [FIX] stock: compute forecasted inventory with inter-wh When moving some products between two warehouses thanks to an internal transfer, the forecasted inventory becomes incorrect To reproduce the issue: 1. In Settings, enable "Multi-Warehouses" 2. Let WH01 be the existing warehouse. Create a second one WH02 3. Create a storable product P 4. Update the quantity of P: - 3 in WH01/Stock 5. Create a planned and internal transfer T: - Source: WH01/Stock - Destination: WH02/Stock - Ignore the warning - Operations: - 1 x P 6. Mark T as todo 7. Open the Forecasted Inventory: - Filters: - Forecasted Stock - Product: P - Group By: Warehouse Error: The report is incorrect, it says there are 3 P in WH01 and 0 in WH02 (should be 2 and 1). It becomes worst if T is done (the quantities in the past become incorrect) We should be able to move a product between two warehouses thanks to an internal transfer (so the warning of the step 5 should be removed). Moreover, the `report.stock.quantity` should consider that use case. When processing a stock move, the SQL view translates it as an in-move or out-move for a specific warehouse. For instance, if the SM comes from a warehouse and goes to a location without any warehouse (e.g., customer location), the SM is considered as an out-move. But here is the issue: in case of an inter-warehouse SM, both source and destination have a defined warehouse. Therefore, we need to: - Duplicate the inter-warehouses SM (so we have one in and one out) - Fake the values (no destination warehouse for the out move, no source warehouse for the in move) Also, before duplicating all SM, we filter out some useless SM: - Draft and cancelled ones - SM done more than 3 months ago (because the report only works for [-3 months; +3 months]) Considering some tests: (SM are confirmed. Each test has been repeated 5 times to get an average) | | | |:-------------------------------------:|:--------:| | 5000 SM, 0% inter-wh, without the fix | ~715 ms | | 5000 SM, 0% inter-wh, with the fix | ~710 ms | | Impact | 0.99x | | | | | 6666 SM, 0% inter-wh, without the fix | ~911 ms | | 5000 SM, 33% inter-wh, with the fix | ~999 ms | | Impact | 1.10x | | | | | 7500 SM, 0% inter-wh, without the fix | ~1004 ms | | 5000 SM, 50% inter-wh, with the fix | ~1097 ms | | Impact | 1.09x | The impact is not that significant OPW-2752017 task-2822157 closes odoo/odoo#91772 X-original-commit: b1efc0243bbf886bbef42a32587f4a818fa34e64 Signed-off-by: Arnold Moyaux Signed-off-by: Adrien Widart --- addons/stock/i18n/stock.pot | 9 -- addons/stock/models/stock_picking.py | 9 -- addons/stock/report/report_stock_quantity.py | 101 +++++++++++------- .../stock/tests/test_report_stock_quantity.py | 60 ++++++++++- 4 files changed, 123 insertions(+), 56 deletions(-) diff --git a/addons/stock/i18n/stock.pot b/addons/stock/i18n/stock.pot index 154b7d5b3ea..79f7f1baf83 100644 --- a/addons/stock/i18n/stock.pot +++ b/addons/stock/i18n/stock.pot @@ -8899,15 +8899,6 @@ msgstr "" msgid "You need to supply a Lot/Serial number for products %s." msgstr "" -#. module: stock -#: code:addons/stock/models/stock_picking.py:0 -#, python-format -msgid "" -"You should not use a planned internal transfer to move some products between" -" two warehouses. Instead, use an immediate internal transfer or the resupply" -" route." -msgstr "" - #. module: stock #: code:addons/stock/models/stock_warehouse.py:0 #, python-format diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 6c4524e59a0..5d0b2f29d65 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -726,15 +726,6 @@ class Picking(models.Model): @api.onchange('location_id', 'location_dest_id') def _onchange_locations(self): - from_wh = self.location_id.warehouse_id - to_wh = self.location_dest_id.warehouse_id - is_immediate = self.immediate_transfer if self._origin else self._context.get('default_immediate_transfer') - if self.picking_type_id.code == 'internal' and not is_immediate and from_wh and to_wh and from_wh != to_wh: - return {'warning': { - 'title': _("Warning"), - 'message': _("You should not use a planned internal transfer to move some products between two warehouses. " - "Instead, use an immediate internal transfer or the resupply route.") - }} (self.move_ids | self.move_ids_without_package).update({ "location_id": self.location_id, "location_dest_id": self.location_dest_id diff --git a/addons/stock/report/report_stock_quantity.py b/addons/stock/report/report_stock_quantity.py index bd7758c4ef6..6592303f5cf 100644 --- a/addons/stock/report/report_stock_quantity.py +++ b/addons/stock/report/report_stock_quantity.py @@ -23,9 +23,55 @@ class ReportStockQuantity(models.Model): warehouse_id = fields.Many2one('stock.warehouse', readonly=True) def init(self): + """ + Because we can transfer a product from a warehouse to another one thanks to a stock move, we need to + generate some fake stock moves before processing all of them. That way, in case of an interwarehouse + transfer, we will have an outgoing stock move for the source warehouse and an incoming stock move + for the destination one. To do so, we select all relevant SM (incoming, outgoing and interwarehouse), + then we duplicate all these SM and edit the values: + - product_qty is kept if the SM is not the duplicated one or if the SM is an interwarehouse one + otherwise, we set the value to 0 (this allows us to filter it out during the SM processing) + - the source warehouse is kept if the SM is not the duplicated one + - the dest warehouse is kept if the SM is not the duplicated one and is not an interwarehouse + OR the SM is the duplicated one and is an interwarehouse + """ tools.drop_view_if_exists(self._cr, 'report_stock_quantity') query = """ CREATE or REPLACE VIEW report_stock_quantity AS ( +WITH + existing_sm (id, product_id, tmpl_id, product_qty, date, state, company_id, whs_id, whd_id) AS ( + SELECT m.id, m.product_id, pt.id, m.product_qty, m.date, m.state, m.company_id, whs.id, whd.id + FROM stock_move m + LEFT JOIN stock_location ls on (ls.id=m.location_id) + LEFT JOIN stock_location ld on (ld.id=m.location_dest_id) + LEFT JOIN stock_warehouse whs ON ls.parent_path like concat('%/', whs.view_location_id, '/%') + LEFT JOIN stock_warehouse whd ON ld.parent_path like concat('%/', whd.view_location_id, '/%') + LEFT JOIN product_product pp on pp.id=m.product_id + LEFT JOIN product_template pt on pt.id=pp.product_tmpl_id + WHERE pt.type = 'product' AND + (whs.id IS NOT NULL OR whd.id IS NOT NULL) AND + (whs.id IS NULL OR whd.id IS NULL OR whs.id != whd.id) AND + m.product_qty != 0 AND + m.state NOT IN ('draft', 'cancel') AND + (m.state != 'done' or m.date >= ((now() at time zone 'utc')::date - interval '3month')) + ), + all_sm (id, product_id, tmpl_id, product_qty, date, state, company_id, whs_id, whd_id) AS ( + SELECT sm.id, sm.product_id, sm.tmpl_id, + CASE + WHEN is_duplicated = 0 THEN sm.product_qty + WHEN sm.whs_id IS NOT NULL AND sm.whd_id IS NOT NULL AND sm.whs_id != sm.whd_id THEN sm.product_qty + ELSE 0 + END, + sm.date, sm.state, sm.company_id, + CASE WHEN is_duplicated = 0 THEN sm.whs_id END, + CASE + WHEN is_duplicated = 0 AND NOT (sm.whs_id IS NOT NULL AND sm.whd_id IS NOT NULL AND sm.whs_id != sm.whd_id) THEN sm.whd_id + WHEN is_duplicated = 1 AND (sm.whs_id IS NOT NULL AND sm.whd_id IS NOT NULL AND sm.whs_id != sm.whd_id) THEN sm.whd_id + END + FROM + GENERATE_SERIES(0, 1, 1) is_duplicated, + existing_sm sm + ) SELECT MIN(id) as id, product_id, @@ -38,35 +84,26 @@ SELECT FROM (SELECT m.id, m.product_id, - pt.id as product_tmpl_id, + m.tmpl_id as product_tmpl_id, CASE - WHEN whs.id IS NOT NULL AND whd.id IS NULL THEN 'out' - WHEN whd.id IS NOT NULL AND whs.id IS NULL THEN 'in' + WHEN m.whs_id IS NOT NULL AND m.whd_id IS NULL THEN 'out' + WHEN m.whd_id IS NOT NULL AND m.whs_id IS NULL THEN 'in' END AS state, m.date::date AS date, CASE - WHEN whs.id IS NOT NULL AND whd.id IS NULL THEN -m.product_qty - WHEN whd.id IS NOT NULL AND whs.id IS NULL THEN m.product_qty + WHEN m.whs_id IS NOT NULL AND m.whd_id IS NULL THEN -m.product_qty + WHEN m.whd_id IS NOT NULL AND m.whs_id IS NULL THEN m.product_qty END AS product_qty, m.company_id, CASE - WHEN whs.id IS NOT NULL AND whd.id IS NULL THEN whs.id - WHEN whd.id IS NOT NULL AND whs.id IS NULL THEN whd.id + WHEN m.whs_id IS NOT NULL AND m.whd_id IS NULL THEN m.whs_id + WHEN m.whd_id IS NOT NULL AND m.whs_id IS NULL THEN m.whd_id END AS warehouse_id FROM - stock_move m - LEFT JOIN stock_location ls on (ls.id=m.location_id) - LEFT JOIN stock_location ld on (ld.id=m.location_dest_id) - LEFT JOIN stock_warehouse whs ON ls.parent_path like concat('%/', whs.view_location_id, '/%') - LEFT JOIN stock_warehouse whd ON ld.parent_path like concat('%/', whd.view_location_id, '/%') - LEFT JOIN product_product pp on pp.id=m.product_id - LEFT JOIN product_template pt on pt.id=pp.product_tmpl_id + all_sm m WHERE - pt.type = 'product' AND m.product_qty != 0 AND - (whs.id IS NOT NULL OR whd.id IS NOT NULL) AND - (whs.id IS NULL OR whd.id IS NULL OR whs.id != whd.id) AND - m.state NOT IN ('cancel', 'draft', 'done') + m.state != 'done' UNION ALL SELECT -q.id as id, @@ -91,7 +128,7 @@ FROM (SELECT SELECT m.id, m.product_id, - pt.id as product_tmpl_id, + m.tmpl_id as product_tmpl_id, 'forecast' as state, GENERATE_SERIES( CASE @@ -103,30 +140,20 @@ FROM (SELECT ELSE m.date::date - interval '1 day' END, '1 day'::interval)::date date, CASE - WHEN whs.id IS NOT NULL AND whd.id IS NULL AND m.state = 'done' THEN m.product_qty - WHEN whd.id IS NOT NULL AND whs.id IS NULL AND m.state = 'done' THEN -m.product_qty - WHEN whs.id IS NOT NULL AND whd.id IS NULL THEN -m.product_qty - WHEN whd.id IS NOT NULL AND whs.id IS NULL THEN m.product_qty + WHEN m.whs_id IS NOT NULL AND m.whd_id IS NULL AND m.state = 'done' THEN m.product_qty + WHEN m.whd_id IS NOT NULL AND m.whs_id IS NULL AND m.state = 'done' THEN -m.product_qty + WHEN m.whs_id IS NOT NULL AND m.whd_id IS NULL THEN -m.product_qty + WHEN m.whd_id IS NOT NULL AND m.whs_id IS NULL THEN m.product_qty END AS product_qty, m.company_id, CASE - WHEN whs.id IS NOT NULL AND whd.id IS NULL THEN whs.id - WHEN whd.id IS NOT NULL AND whs.id IS NULL THEN whd.id + WHEN m.whs_id IS NOT NULL AND m.whd_id IS NULL THEN m.whs_id + WHEN m.whd_id IS NOT NULL AND m.whs_id IS NULL THEN m.whd_id END AS warehouse_id FROM - stock_move m - LEFT JOIN stock_location ls on (ls.id=m.location_id) - LEFT JOIN stock_location ld on (ld.id=m.location_dest_id) - LEFT JOIN stock_warehouse whs ON ls.parent_path like concat('%/', whs.view_location_id, '/%') - LEFT JOIN stock_warehouse whd ON ld.parent_path like concat('%/', whd.view_location_id, '/%') - LEFT JOIN product_product pp on pp.id=m.product_id - LEFT JOIN product_template pt on pt.id=pp.product_tmpl_id + all_sm m WHERE - pt.type = 'product' AND - m.product_qty != 0 AND - (whs.id IS NOT NULL OR whd.id IS NOT NULL) AND - (whs.id IS NULL or whd.id IS NULL OR whs.id != whd.id) AND - m.state NOT IN ('cancel', 'draft')) as forecast_qty + m.product_qty != 0) AS forecast_qty GROUP BY product_id, product_tmpl_id, state, date, company_id, warehouse_id ); """ diff --git a/addons/stock/tests/test_report_stock_quantity.py b/addons/stock/tests/test_report_stock_quantity.py index b3d78e734bf..d37b241487c 100644 --- a/addons/stock/tests/test_report_stock_quantity.py +++ b/addons/stock/tests/test_report_stock_quantity.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import timedelta +from datetime import datetime, timedelta from odoo import fields, tests from odoo.tests.common import Form @@ -184,3 +184,61 @@ class TestReportStockQuantity(tests.TransactionCase): self.assertEqual(orderpoint.qty_to_order, 0.0) self.env['stock.warehouse.orderpoint'].action_open_orderpoints() self.assertEqual(orderpoint.qty_to_order, 0.0) + + def test_inter_warehouse_transfer(self): + """ + Ensure that the report correctly processes the inter-warehouses SM + """ + product = self.env['product.product'].create({ + 'name': 'SuperProduct', + 'type': 'product', + }) + + today = datetime.now() + two_days_ago = today - timedelta(days=2) + in_two_days = today + timedelta(days=2) + + wh01, wh02 = self.env['stock.warehouse'].create([{ + 'name': 'Warehouse 01', + 'code': 'WH01', + }, { + 'name': 'Warehouse 02', + 'code': 'WH02', + }]) + + self.env['stock.quant']._update_available_quantity(product, wh01.lot_stock_id, 3, in_date=two_days_ago) + + # Let's have 2 inter-warehouses stock moves (one for today and one for two days from now) + move01, move02 = self.env['stock.move'].create([{ + 'name': 'Inter WH Move', + 'location_id': wh01.lot_stock_id.id, + 'location_dest_id': wh02.lot_stock_id.id, + 'product_id': product.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': 1, + 'date': date, + } for date in (today, in_two_days)]) + + (move01 + move02)._action_confirm() + move01.quantity_done = 1 + move01._action_done() + + self.env['stock.move'].flush() + + data = self.env['report.stock.quantity'].read_group( + [('state', '=', 'forecast'), ('product_id', '=', product.id), ('date', '>=', two_days_ago), ('date', '<=', in_two_days)], + ['product_qty', 'date', 'warehouse_id'], + ['date:day', 'warehouse_id'], + orderby='date, warehouse_id', + lazy=False, + ) + + for row, qty in zip(data, [ + # wh01_qty, wh02_qty + 3.0, 0.0, # two days ago + 3.0, 0.0, + 2.0, 1.0, # today + 2.0, 1.0, + 1.0, 2.0, # in two days + ]): + self.assertEqual(row['product_qty'], qty, "Incorrect qty for Date '%s' Warehouse '%s'" % (row['date:day'], row['warehouse_id'][1]))