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