[IMP] stock: replenishment locations

this commit will allow users to setup replenish locations and replenish
them directly if they have negative forecast
instead of checking forecast qty per warehouse,
this adds the abiltiy to replenish to any internal stock location,
marked as replenish location, instead of just the warehouse location

Task Id: 2796757

closes odoo/odoo#93633

Related: odoo/upgrade#3626
Signed-off-by: William Henrotin (whe) <whe@odoo.com>
This commit is contained in:
Ahmed Khalaf (ahkh)
2022-08-03 15:09:19 +02:00
parent d2a59acc6b
commit 32fc7661b3
6 changed files with 106 additions and 66 deletions
+17
View File
@@ -70,6 +70,8 @@ class Location(models.Model):
help='Let this field empty if this location is shared between companies')
scrap_location = fields.Boolean('Is a Scrap Location?', default=False, help='Check this box to allow using this location to put scrapped/damaged goods.')
return_location = fields.Boolean('Is a Return Location?', help='Check this box to allow using this location as a return location.')
replenish_location = fields.Boolean('Replenish Location', copy=False, compute="_compute_replenish_location", readonly=False, store=True,
help='Activate this function to get all quantities to replenish at this particular location')
removal_strategy_id = fields.Many2one(
'product.removal', 'Removal Strategy',
help="Defines the default method used for suggesting the exact location (shelf) "
@@ -167,6 +169,21 @@ class Location(models.Model):
if self.usage not in ('internal', 'inventory'):
self.scrap_location = False
@api.depends('usage')
def _compute_replenish_location(self):
for loc in self:
if loc.usage != 'internal':
loc.replenish_location = False
@api.constrains('replenish_location', 'location_id', 'usage')
def _check_replenish_location(self):
for loc in self:
if loc.replenish_location:
# cannot have parent/child location set as replenish as well
replenish_wh_location = self.search([('id', '!=', loc.id), ('replenish_location', '=', True), '|', ('location_id', 'child_of', loc.id), ('location_id', 'parent_of', loc.id)], limit=1)
if replenish_wh_location:
raise ValidationError(_('Another parent/sub replenish location %s exists, if you wish to change it, uncheck it first', replenish_wh_location.name))
@api.constrains('scrap_location')
def _check_scrap_location(self):
for record in self:
+43 -63
View File
@@ -335,82 +335,62 @@ class StockWarehouseOrderpoint(models.Model):
orderpoints_removed = orderpoints._unlink_processed_orderpoints()
orderpoints = orderpoints - orderpoints_removed
to_refill = defaultdict(float)
all_product_ids = []
all_warehouse_ids = []
# Take 3 months since it's the max for the forecast report
to_date = add(fields.date.today(), months=3)
qty_by_product_warehouse = self.env['report.stock.quantity']._read_group(
[('date', '=', to_date), ('state', '=', 'forecast')],
['product_id', 'product_qty', 'warehouse_id'],
['product_id', 'warehouse_id'], lazy=False)
for group in qty_by_product_warehouse:
warehouse_id = group.get('warehouse_id') and group['warehouse_id'][0]
if group['product_qty'] >= 0.0 or not warehouse_id:
continue
all_product_ids.append(group['product_id'][0])
all_warehouse_ids.append(warehouse_id)
to_refill[(group['product_id'][0], warehouse_id)] = group['product_qty']
if not to_refill:
return action
all_product_ids = self.env['product.product'].search([('type', '=', 'product')])
all_replenish_location_ids = self.env['stock.location'].search([('replenish_location', '=', True)])
ploc_per_day = defaultdict(set)
product_loc_by_qty = {}
# For each replenish location get products with negative virtual_available aka forecast
for loc in all_replenish_location_ids:
quantities = all_product_ids.with_context(location=loc.id).mapped('virtual_available')
for product, quantity in zip(all_product_ids, quantities):
if float_compare(quantity, 0, precision_rounding=product.uom_id.rounding) >= 0:
continue
product_loc_by_qty[(product, loc)] = quantity
# Recompute the forecasted quantity for missing product today but at this time
# with their real lead days.
key_to_remove = []
pwh_per_day = defaultdict(list)
for (product, warehouse) in to_refill.keys():
product = self.env['product.product'].browse(product).with_prefetch(all_product_ids)
warehouse = self.env['stock.warehouse'].browse(warehouse).with_prefetch(all_warehouse_ids)
rules = product._get_rules_from_location(warehouse.lot_stock_id)
lead_days = rules.with_context(bypass_delay_description=True)._get_lead_days(product)[0]
pwh_per_day[(lead_days, warehouse)].append(product.id)
# group product by lead_days and warehouse in order to read virtual_available
# group product by lead_days and location in order to read virtual_available
# in batch
for (product, loc), qty in product_loc_by_qty.items():
rules = product._get_rules_from_location(loc)
lead_days = rules.with_context(bypass_delay_description=True)._get_lead_days(product)[0]
ploc_per_day[(lead_days, loc)].add(product.id)
# recompute virtual_available with lead days
today = fields.datetime.now().replace(hour=23, minute=59, second=59)
for (days, warehouse), p_ids in pwh_per_day.items():
products = self.env['product.product'].browse(p_ids)
for (days, loc), product_ids in ploc_per_day.items():
products = self.env['product.product'].browse(product_ids)
qties = products.with_context(
warehouse=warehouse.id,
location=loc.id,
to_date=today + relativedelta.relativedelta(days=days)
).read(['virtual_available'])
for qty in qties:
if float_compare(qty['virtual_available'], 0, precision_rounding=product.uom_id.rounding) >= 0:
key_to_remove.append((qty['id'], warehouse.id))
else:
to_refill[(qty['id'], warehouse.id)] = qty['virtual_available']
for key in key_to_remove:
del to_refill[key]
if float_compare(qty['virtual_available'], 0, precision_rounding=product.uom_id.rounding) < 0:
to_refill[(qty['id'], loc.id)] = qty['virtual_available']
if not to_refill:
return action
# Remove incoming quantity from other origin than moves (e.g RFQ)
product_ids, warehouse_ids = zip(*to_refill)
dummy, qty_by_product_wh = self.env['product.product'].browse(product_ids)._get_quantity_in_progress(warehouse_ids=warehouse_ids)
product_ids, location_ids = zip(*to_refill)
qty_by_product_loc, dummy = self.env['product.product'].browse(product_ids)._get_quantity_in_progress(location_ids=location_ids)
rounding = self.env['decimal.precision'].precision_get('Product Unit of Measure')
# Group orderpoint by product-warehouse
orderpoint_by_product_warehouse = self.env['stock.warehouse.orderpoint']._read_group(
# Group orderpoint by product-location
orderpoint_by_product_location = self.env['stock.warehouse.orderpoint']._read_group(
[('id', 'in', orderpoints.ids)],
['product_id', 'warehouse_id', 'qty_to_order:sum'],
['product_id', 'warehouse_id'], lazy=False)
orderpoint_by_product_warehouse = {
(record.get('product_id')[0], record.get('warehouse_id')[0]): record.get('qty_to_order')
for record in orderpoint_by_product_warehouse
['product_id', 'location_id', 'qty_to_order:sum'],
['product_id', 'location_id'], lazy=False)
orderpoint_by_product_location = {
(record.get('product_id')[0], record.get('location_id')[0]): record.get('qty_to_order')
for record in orderpoint_by_product_location
}
for (product, warehouse), product_qty in to_refill.items():
qty_in_progress = qty_by_product_wh.get((product, warehouse)) or 0.0
qty_in_progress += orderpoint_by_product_warehouse.get((product, warehouse), 0.0)
# Add qty to order for other orderpoint under this warehouse.
for (product, location), product_qty in to_refill.items():
qty_in_progress = qty_by_product_loc.get((product, location)) or 0.0
qty_in_progress += orderpoint_by_product_location.get((product, location), 0.0)
# Add qty to order for other orderpoint under this location.
if not qty_in_progress:
continue
to_refill[(product, warehouse)] = product_qty + qty_in_progress
to_refill[(product, location)] = product_qty + qty_in_progress
to_refill = {k: v for k, v in to_refill.items() if float_compare(
v, 0.0, precision_digits=rounding) < 0.0}
lot_stock_id_by_warehouse = self.env['stock.warehouse'].with_context(active_test=False).search_read([
('id', 'in', [g[1] for g in to_refill.keys()])
], ['lot_stock_id'])
lot_stock_id_by_warehouse = {w['id']: w['lot_stock_id'][0] for w in lot_stock_id_by_warehouse}
# With archived ones to avoid `product_location_check` SQL constraints
orderpoint_by_product_location = self.env['stock.warehouse.orderpoint'].with_context(active_test=False)._read_group(
[('id', 'in', orderpoints.ids)],
@@ -422,17 +402,17 @@ class StockWarehouseOrderpoint(models.Model):
}
orderpoint_values_list = []
for (product, warehouse), product_qty in to_refill.items():
lot_stock_id = lot_stock_id_by_warehouse[warehouse]
orderpoint_id = orderpoint_by_product_location.get((product, lot_stock_id))
for (product, location_id), product_qty in to_refill.items():
orderpoint_id = orderpoint_by_product_location.get((product, location_id))
if orderpoint_id:
self.env['stock.warehouse.orderpoint'].browse(orderpoint_id).qty_forecast += product_qty
else:
orderpoint_values = self.env['stock.warehouse.orderpoint']._get_orderpoint_values(product, lot_stock_id)
orderpoint_values = self.env['stock.warehouse.orderpoint']._get_orderpoint_values(product, location_id)
warehouse_id = self.env['stock.location'].browse(location_id).warehouse_id
orderpoint_values.update({
'name': _('Replenishment Report'),
'warehouse_id': warehouse,
'company_id': self.env['stock.warehouse'].browse(warehouse).company_id.id,
'warehouse_id': warehouse_id.id,
'company_id': warehouse_id.company_id.id,
})
orderpoint_values_list.append(orderpoint_values)
+1
View File
@@ -601,6 +601,7 @@ class Warehouse(models.Model):
'name': _('Stock'),
'active': True,
'usage': 'internal',
'replenish_location': True,
'barcode': self._valid_barcode(code + '-STOCK', company_id)
},
'wh_input_stock_loc_id': {
+38 -1
View File
@@ -326,7 +326,7 @@ class TestProcRule(TransactionCase):
self.assertAlmostEqual(picking_pick.move_ids.product_uom_qty, 5.0)
self.assertAlmostEqual(picking_ship.move_ids.product_uom_qty, 3.0)
def test_orderpoint_replenishment_view(self):
def test_orderpoint_replenishment_view_1(self):
""" Create two warehouses + two moves
verify that the replenishment view is consistent"""
warehouse_1 = self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1)
@@ -380,6 +380,43 @@ class TestProcRule(TransactionCase):
{'location_id': warehouse_3.lot_stock_id.id, 'route_id': route_3.id},
])
def test_orderpoint_replenishment_view_2(self):
""" Create a warehouse + location to replenish warehouse instead of main location
verify that the orderpoints created are for the replenish locations not the warehouse main location"""
warehouse_1 = self.env['stock.warehouse'].create({
'name': 'Warehouse 1',
'code': 'WH1',
})
warehouse_1.lot_stock_id.replenish_location = False
replenish_loc = self.env['stock.location'].create({
'name': 'Replenish Location',
'location_id': warehouse_1.lot_stock_id.id,
'replenish_location': True,
})
product = self.env['product.product'].create({
'name': 'Rep Product',
'type': 'product',
})
move = self.env['stock.move'].create({
'name': 'Move WH2',
'location_id': replenish_loc.id,
'location_dest_id': self.partner.property_stock_customer.id,
'product_id': product.id,
'product_uom': product.uom_id.id,
'product_uom_qty': 3,
})
move._action_confirm()
# activate action of opening the replenishment view
self.env.flush_all()
self.env['stock.warehouse.orderpoint'].action_open_orderpoints()
replenishments = self.env['stock.warehouse.orderpoint'].search([
('product_id', '=', product.id),
])
# Verify the location and the qty
self.assertRecordValues(replenishments, [
{'location_id': replenish_loc.id, 'qty_to_order': 3},
])
def test_orderpoint_compute_warehouse_location(self):
warehouse_a = self.env['stock.warehouse'].search([], limit=1)
warehouse_b = self.env['stock.warehouse'].create({
@@ -39,6 +39,7 @@
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
<field name="scrap_location" attrs="{'invisible': [('usage', 'not in', ('inventory', 'internal'))]}"/>
<field name="return_location"/>
<field name="replenish_location" attrs="{'invisible': [('usage', '!=', 'internal')]}"/>
</group>
<group string="Cyclic Counting" attrs="{'invisible': ['|', ('usage', 'not in', ('internal', 'transit')), ('company_id', '=', False)]}">
<field name="cyclic_inventory_frequency"/>
@@ -92,6 +92,10 @@
<filter string="Product" name="groupby_product" domain="[]" context="{'group_by': 'product_id'}"/>
<filter string="Category" name="groupby_category" domain="[]" context="{'group_by': 'product_category_id'}"/>
</group>
<searchpanel>
<field name="location_id" string="Locations" groups="stock.group_stock_multi_locations" enable_counters="1"/>
<field name="trigger" string="Trigger" enable_counters="1"/>
</searchpanel>
</search>
</field>
</record>
@@ -211,8 +215,8 @@
action = model.with_context(
search_default_filter_to_reorder=True,
search_default_filter_not_snoozed=True,
search_default_filter_creation_trigger=True,
default_trigger='manual'
default_trigger='manual',
searchpanel_default_trigger='manual'
).action_open_orderpoints()
</field>
</record>