[IMP] stock: Warning on Replenishing too much
**Steps to reproduce :** - Create a Product A, with no stock and a 0 min 1 max manual reordering rule (no sales lead time, no purchase lead time) - Sell 10 of A with a delivery scheduled today - In Replenishment, create a PO for A, confirm it with reception scheduled today - Reschedule reception of PO in 7 days **Expected Behavior :** The forecast icon is replaced by an orange warning icon. When hovering the icon, the user is warned that replenishing can lead to an overstock. **Current behavior :** In Replenishment, Product A has a forecast of -10 and Qty to Order is set to 11. When the user replenish A again, the PO is created and may lead to an overstock. task : 2918947 closes odoo/odoo#97802 Signed-off-by: Arnold Moyaux (arm) <arm@odoo.com>
This commit is contained in:
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import test_sale_purchase_stock_flow
|
||||
from . import test_access_rights
|
||||
from . import test_unwanted_replenish_flow
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import Command
|
||||
from odoo.tests import common, Form, tagged
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestWarnUnwantedReplenish(common.TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.buy_route = cls.env.ref('purchase_stock.route_warehouse0_buy')
|
||||
|
||||
# Create a vendor (& suppliers) and a customer
|
||||
cls.vendor = cls.env['res.partner'].create(dict(name='Vendor'))
|
||||
cls.customer = cls.env['res.partner'].create(dict(name='Customer'))
|
||||
|
||||
cls.supplier_A = cls.env['product.supplierinfo'].create({
|
||||
'partner_id' : cls.vendor.id,
|
||||
'min_qty' : 0.0,
|
||||
'price' : 10.0,
|
||||
'delay' : 0
|
||||
})
|
||||
|
||||
cls.supplier_B = cls.env['product.supplierinfo'].create({
|
||||
'partner_id' : cls.vendor.id,
|
||||
'min_qty' : 0.0,
|
||||
'price' : 12.0,
|
||||
'delay' : 0
|
||||
})
|
||||
|
||||
# Create a "A" and a "B" Product :
|
||||
# No Stock
|
||||
# Partner/Customer Lead Time = 0
|
||||
# Manual reordering 0 0
|
||||
|
||||
cls.product_A = cls.env['product.product'].create({
|
||||
'name': 'Product A',
|
||||
'type': 'product',
|
||||
'categ_id': cls.env.ref('product.product_category_all').id,
|
||||
'purchase_method': 'purchase',
|
||||
'invoice_policy': 'delivery',
|
||||
'standard_price': 5.0,
|
||||
'list_price': 10.0,
|
||||
'seller_ids': [Command.link(cls.supplier_A.id)],
|
||||
'route_ids': [Command.link(cls.buy_route.id)],
|
||||
'sale_delay' : 0,
|
||||
})
|
||||
|
||||
cls.product_B = cls.env['product.product'].create({
|
||||
'name': 'Product B',
|
||||
'type': 'product',
|
||||
'categ_id': cls.env.ref('product.product_category_all').id,
|
||||
'purchase_method': 'purchase',
|
||||
'invoice_policy': 'delivery',
|
||||
'standard_price': 6.0,
|
||||
'list_price': 12.0,
|
||||
'seller_ids': [Command.link(cls.supplier_B.id)],
|
||||
'route_ids': [Command.link(cls.buy_route.id)],
|
||||
'sale_delay': 0,
|
||||
})
|
||||
|
||||
|
||||
orderpoint_form = Form(cls.env['stock.warehouse.orderpoint'])
|
||||
orderpoint_form.product_id = cls.product_A
|
||||
orderpoint_form.product_min_qty = 0.0
|
||||
orderpoint_form.product_max_qty = 1.0
|
||||
cls.orderpoint_A = orderpoint_form.save()
|
||||
cls.orderpoint_A.trigger = 'manual'
|
||||
|
||||
orderpoint_form = Form(cls.env['stock.warehouse.orderpoint'])
|
||||
orderpoint_form.product_id = cls.product_B
|
||||
orderpoint_form.product_min_qty = 0.0
|
||||
orderpoint_form.product_max_qty = 1.0
|
||||
cls.orderpoint_B = orderpoint_form.save()
|
||||
cls.orderpoint_B.trigger = 'manual'
|
||||
|
||||
# Create Sales
|
||||
# For A and for B
|
||||
# Delivered today
|
||||
# Confirm SO
|
||||
|
||||
cls.sale_order = cls.env['sale.order'].create({
|
||||
'partner_id': cls.customer.id,
|
||||
'order_line': [
|
||||
Command.create({
|
||||
'product_id': cls.product_A.id,
|
||||
'product_uom_qty': 10,
|
||||
}),
|
||||
Command.create({
|
||||
'product_id': cls.product_B.id,
|
||||
'product_uom_qty': 10,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
cls.sale_order.action_confirm()
|
||||
|
||||
# Create PO for Product A
|
||||
# Confirm PO with date planned : TODAY
|
||||
# Incoming Picking : reschedule in one week
|
||||
|
||||
cls.po_A = cls.env['purchase.order'].create({
|
||||
'partner_id': cls.vendor.id,
|
||||
'order_line': [
|
||||
Command.create({
|
||||
'name': cls.product_A.name,
|
||||
'product_id': cls.product_A.id,
|
||||
'product_qty': 10.0,
|
||||
'price_unit': 10.0,
|
||||
'date_planned': datetime.today(),
|
||||
})],
|
||||
})
|
||||
|
||||
cls.po_A.button_confirm()
|
||||
|
||||
cls.picking_A = cls.po_A.picking_ids[0]
|
||||
cls.picking_A.scheduled_date = (datetime.today() + timedelta(days=10))
|
||||
|
||||
def test_01_pre_updateA_post(self):
|
||||
"""
|
||||
TEST 1
|
||||
Replenishment ->
|
||||
Product A
|
||||
unwanted_replenish SHALL be TRUE
|
||||
Product B
|
||||
unwanted_replenish SHALL be FALSE
|
||||
Product A
|
||||
Modify Visible Days past 1 Week -> unwanted_replenish SHALL be FALSE
|
||||
"""
|
||||
self.assertTrue(self.orderpoint_A.unwanted_replenish, 'Orderpoint A not set to unwanted_replenish')
|
||||
self.assertFalse(self.orderpoint_B.unwanted_replenish, 'Orderpoint B is set to unwanted_replenish')
|
||||
#Update Orderpoint A
|
||||
self.orderpoint_A.visibility_days = 10
|
||||
self.assertFalse(self.orderpoint_A.unwanted_replenish, 'Orderpoint A shall not be set to unwanted_replenish')
|
||||
@@ -11,7 +11,7 @@ from odoo import SUPERUSER_ID, _, api, fields, models, registry
|
||||
from odoo.addons.stock.models.stock_rule import ProcurementException
|
||||
from odoo.exceptions import RedirectWarning, UserError, ValidationError
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_compare, frozendict, split_every
|
||||
from odoo.tools import float_compare, float_is_zero, frozendict, split_every
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -94,6 +94,8 @@ class StockWarehouseOrderpoint(models.Model):
|
||||
help="Consider product forecast these many days in the future upon product replenishment, set to 0 for just-in-time.\n"
|
||||
"The value depends on the type of the route (Buy or Manufacture)")
|
||||
|
||||
unwanted_replenish = fields.Boolean('Unwanted Replenish', compute="_compute_unwanted_replenish")
|
||||
|
||||
_sql_constraints = [
|
||||
('qty_multiple_check', 'CHECK( qty_multiple >= 0 )', 'Qty Multiple must be greater than or equal to zero.'),
|
||||
('product_location_check', 'unique (product_id, location_id, company_id)', 'A replenishment rule already exists for this product on this location.'),
|
||||
@@ -169,6 +171,15 @@ class StockWarehouseOrderpoint(models.Model):
|
||||
], limit=1)
|
||||
orderpoint.location_id = warehouse.lot_stock_id.id
|
||||
|
||||
@api.depends('product_id', 'qty_to_order', 'product_max_qty')
|
||||
def _compute_unwanted_replenish(self):
|
||||
for orderpoint in self:
|
||||
if not orderpoint.product_id or float_is_zero(orderpoint.qty_to_order, precision_rounding=orderpoint.product_uom.rounding) or float_is_zero(orderpoint.product_max_qty, precision_rounding=orderpoint.product_uom.rounding):
|
||||
orderpoint.unwanted_replenish = False
|
||||
else:
|
||||
after_replenish_qty = orderpoint.product_id.with_context(company_id=orderpoint.company_id.id, location=orderpoint.location_id.id).virtual_available + orderpoint.qty_to_order
|
||||
orderpoint.unwanted_replenish = float_compare(after_replenish_qty, orderpoint.product_max_qty, precision_rounding=orderpoint.product_uom.rounding) > 0
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
|
||||
@@ -43,12 +43,14 @@
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="product_category_id" invisible="1"/>
|
||||
<field name="product_tmpl_id" invisible="1"/>
|
||||
<field name="unwanted_replenish" invisible="1"/>
|
||||
<field name="product_id" attrs="{'readonly': [('product_id', '!=', False)]}" force_save="1"/>
|
||||
<field name="location_id" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/>
|
||||
<field name="warehouse_id" options="{'no_create': True}" groups="stock.group_stock_multi_warehouses" optional="hide"/>
|
||||
<field name="qty_on_hand" force_save="1"/>
|
||||
<field name="qty_forecast" force_save="1"/>
|
||||
<button name="action_product_forecast_report" type="object" icon="fa-area-chart" title="Forecast Report" attrs="{'invisible': [('id', '=', False)]}"/>
|
||||
<button name="action_product_forecast_report" type="object" icon="fa-area-chart" title="Forecast Report" attrs="{'invisible': ['|', ('id', '=', False), ('unwanted_replenish', '=', True)]}"/>
|
||||
<button name="action_product_forecast_report" type="object" icon="fa-warning text-warning" title="Due to receipts scheduled in the future, you might end up with excessive stock . Check the Forecasted Report before reordering" attrs="{'invisible': ['|', ('id', '=', False), ('unwanted_replenish', '=', False)]}"/>
|
||||
<field name="visibility_days" optional="hidden"/>
|
||||
<field name="route_id" options="{'no_create': True, 'no_open': True}" optional="hidden"/>
|
||||
<button name="action_stock_replenishment_info" type="object" icon="fa-info-circle" title="Replenishment Information" attrs="{'invisible': [('id', '=', False)]}"/>
|
||||
|
||||
Reference in New Issue
Block a user