[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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user