[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 <arm@odoo.com>
Signed-off-by: Adrien Widart <awt@odoo.com>
This commit is contained in:
Adrien Widart
2022-05-19 16:40:23 +02:00
parent 98bb94beef
commit c7072eaba6
4 changed files with 123 additions and 56 deletions
-9
View File
@@ -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
-9
View File
@@ -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
+64 -37
View File
@@ -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
);
"""
@@ -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]))