19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:47 +01:00
parent accf5918df
commit 6e65e8c877
688 changed files with 225434 additions and 199401 deletions

View file

@ -1,13 +1,15 @@
# -*- coding: utf-8 -*-
from collections import defaultdict, OrderedDict
from datetime import date, timedelta
from datetime import date, datetime, time, timedelta
import json
from odoo import api, fields, models, _
from odoo.tools import float_compare, float_round, format_date, float_is_zero
from odoo.tools import float_compare, float_round, format_date, float_is_zero, float_repr
from odoo.exceptions import UserError
class ReportBomStructure(models.AbstractModel):
class ReportMrpReport_Bom_Structure(models.AbstractModel):
_name = 'report.mrp.report_bom_structure'
_description = 'BOM Overview Report'
@ -19,7 +21,7 @@ class ReportBomStructure(models.AbstractModel):
@api.model
def get_warehouses(self):
return self.env['stock.warehouse'].search_read([('company_id', 'in', self.env.companies.ids)], fields=['id', 'name'])
return self.env['stock.warehouse'].search_read([('company_id', 'in', self.env.companies.ids)], fields=['id', 'name', 'manu_type_id'])
@api.model
def _compute_current_production_capacity(self, bom_data):
@ -27,44 +29,25 @@ class ReportBomStructure(models.AbstractModel):
components_qty_to_produce = defaultdict(lambda: 0)
components_qty_available = {}
for comp in bom_data.get('components', []):
if comp['product'].type != 'product' or float_is_zero(comp['base_bom_line_qty'], precision_rounding=comp['uom'].rounding):
if not comp['product'].is_storable or comp['uom'].is_zero(comp['base_bom_line_qty']):
continue
components_qty_to_produce[comp['product_id']] += comp['base_bom_line_qty']
components_qty_available[comp['product_id']] = comp['quantity_available']
components_qty_available[comp['product_id']] = comp['free_to_manufacture_qty']
producibles = [float_round(components_qty_available[p_id] / qty, precision_digits=0, rounding_method='DOWN') for p_id, qty in components_qty_to_produce.items()]
return min(producibles) * bom_data['bom']['product_qty'] if producibles else 0
@api.model
def _compute_production_capacities(self, bom_qty, bom_data):
date_today = self.env.context.get('from_date', fields.date.today())
same_delay = bom_data['lead_time'] == bom_data['availability_delay']
res = {}
if bom_data.get('producible_qty', 0):
# Check if something is producible today, at the earliest time possible considering product's lead time.
res['earliest_capacity'] = bom_data['producible_qty']
res['earliest_date'] = format_date(self.env, date_today + timedelta(days=bom_data['lead_time']))
if bom_data['availability_state'] != 'unavailable':
if same_delay:
# Means that stock will be resupplied at date_today, so the whole manufacture can start at date_today.
res['earliest_capacity'] = bom_qty
res['earliest_date'] = format_date(self.env, date_today + timedelta(days=bom_data['lead_time']))
else:
res['leftover_capacity'] = bom_qty - bom_data.get('producible_qty', 0)
res['leftover_date'] = format_date(self.env, date_today + timedelta(days=bom_data['availability_delay']))
return res
@api.model
def _get_report_values(self, docids, data=None):
docs = []
for bom_id in docids:
bom = self.env['mrp.bom'].browse(bom_id)
if not bom:
continue
variant = data.get('variant')
candidates = variant and self.env['product.product'].browse(int(variant)) or bom.product_id or bom.product_tmpl_id.product_variant_ids
quantity = float(data.get('quantity', bom.product_qty))
if data.get('warehouse_id'):
self = self.with_context(warehouse=int(data.get('warehouse_id')))
self = self.with_context(warehouse_id=int(data.get('warehouse_id'))) # noqa: PLW0642
for product_variant_id in candidates.ids:
docs.append(self._get_pdf_doc(bom_id, data, quantity, product_variant_id))
if not candidates:
@ -81,10 +64,7 @@ class ReportBomStructure(models.AbstractModel):
doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=quantity, unfolded_ids=set(json.loads(data.get('unfolded_ids'))))
else:
doc = self._get_pdf_line(bom_id, product_id=product_variant_id, qty=quantity, unfolded=True)
doc['show_availabilities'] = False if data and data.get('availabilities') == 'false' else True
doc['show_costs'] = False if data and data.get('costs') == 'false' else True
doc['show_operations'] = False if data and data.get('operations') == 'false' else True
doc['show_lead_times'] = False if data and data.get('lead_times') == 'false' else True
doc['forecast_mode'] = data.get('mode', 'overview') == 'forecast'
return doc
@api.model
@ -108,26 +88,24 @@ class ReportBomStructure(models.AbstractModel):
for variant in bom.product_tmpl_id.product_variant_ids:
bom_product_variants[variant.id] = variant.display_name
if self.env.context.get('warehouse'):
warehouse = self.env['stock.warehouse'].browse(self.env.context.get('warehouse'))
if self.env.context.get('warehouse_id'):
warehouse = self.env['stock.warehouse'].browse(self.env.context.get('warehouse_id'))
else:
warehouse = self.env['stock.warehouse'].browse(self.get_warehouses()[0]['id'])
lines = self._get_bom_data(bom, warehouse, product=product, line_qty=bom_quantity, level=0)
production_capacities = self._compute_production_capacities(bom_quantity, lines)
lines.update(production_capacities)
return {
'lines': lines,
'variants': bom_product_variants,
'bom_uom_name': bom_uom_name,
'bom_qty': bom_quantity,
'is_variant_applied': self.env.user.user_has_groups('product.group_product_variant') and len(bom_product_variants) > 1,
'is_uom_applied': self.env.user.user_has_groups('uom.group_uom'),
'precision': self.env['decimal.precision'].precision_get('Product Unit of Measure'),
'is_variant_applied': self.env.user.has_group('product.group_product_variant') and len(bom_product_variants) > 1,
'is_uom_applied': self.env.user.has_group('uom.group_uom'),
'precision': self.env['decimal.precision'].precision_get('Product Unit'),
}
@api.model
def _get_components_closest_forecasted(self, lines, line_quantities, parent_bom, product_info, ignore_stock=False):
def _get_components_closest_forecasted(self, lines, line_quantities, parent_bom, product_info, parent_product, ignore_stock=False):
"""
Returns a dict mapping products to a dict of their corresponding BoM lines,
which are mapped to their closest date in the forecast report where consumed quantity >= forecasted quantity.
@ -146,34 +124,35 @@ class ReportBomStructure(models.AbstractModel):
product_quantities_info = defaultdict(OrderedDict)
for line in lines:
product = line.product_id
quantities_info = self._get_quantities_info(product, line.product_uom_id, parent_bom, product_info)
line_quantity = line_quantities.get(line.id, 0.0)
quantities_info = self._get_quantities_info(product, line.product_uom_id, product_info, parent_bom, parent_product)
stock_loc = quantities_info['stock_loc']
product_info[product.id]['consumptions'][stock_loc] += line_quantities.get(line.id, 0.0)
product_info[product.id]['consumptions'][stock_loc] += line_quantity
product_quantities_info[product.id][line.id] = product_info[product.id]['consumptions'][stock_loc]
if (product.detailed_type != 'product' or
float_compare(product_info[product.id]['consumptions'][stock_loc], quantities_info['free_qty'], precision_rounding=product.uom_id.rounding) <= 0):
if (not product.is_storable or
product.uom_id.compare(product_info[product.id]['consumptions'][stock_loc], quantities_info['free_qty']) <= 0):
# Use date.min as a sentinel value for _get_stock_availability
closest_forecasted[product.id][line.id] = date.min
elif stock_loc != 'in_stock':
elif stock_loc != 'in_stock' or quantities_info['forecasted_qty'] < line_quantity:
closest_forecasted[product.id][line.id] = date.max
else:
remaining_products.append(product.id)
closest_forecasted[product.id][line.id] = None
date_today = self.env.context.get('from_date', fields.date.today())
date_today = self.env.context.get('from_date', fields.Date.today())
domain = [('state', '=', 'forecast'), ('date', '>=', date_today), ('product_id', 'in', list(set(remaining_products)))]
if self.env.context.get('warehouse'):
domain.append(('warehouse_id', '=', self.env.context.get('warehouse')))
if self.env.context.get('warehouse_id'):
domain.append(('warehouse_id', '=', self.env.context.get('warehouse_id')))
if remaining_products:
res = self.env['report.stock.quantity']._read_group(
domain,
['min_date:min(date)', 'product_id', 'product_qty'],
['product_id', 'product_qty'],
orderby='product_id asc, min_date asc', lazy=False
groupby=['product_id', 'product_qty'],
aggregates=['date:min'],
order='product_id asc, date:min asc'
)
available_quantities = defaultdict(list)
for group in res:
product_id = group['product_id'][0]
available_quantities[product_id].append([group['min_date'], group['product_qty']])
product_id = group[0].id
available_quantities[product_id].append([group[2], group[1]])
for product_id in remaining_products:
# Find the first empty line_id for the given product_id.
line_id = next(filter(lambda k: not closest_forecasted[product_id][k], closest_forecasted[product_id].keys()), None)
@ -187,7 +166,7 @@ class ReportBomStructure(models.AbstractModel):
return closest_forecasted
@api.model
def _get_bom_data(self, bom, warehouse, product=False, line_qty=False, bom_line=False, level=0, parent_bom=False, index=0, product_info=False, ignore_stock=False):
def _get_bom_data(self, bom, warehouse, product=False, line_qty=False, bom_line=False, level=0, parent_bom=False, parent_product=False, index=0, product_info=False, ignore_stock=False, simulated_leaves_per_workcenter=False):
""" Gets recursively the BoM and all its subassemblies and computes availibility estimations for each component and their disponibility in stock.
Accepts specific keys in context that will affect the data computed :
- 'minimized': Will cut all data not required to compute availability estimations.
@ -198,37 +177,36 @@ class ReportBomStructure(models.AbstractModel):
product = bom.product_id or bom.product_tmpl_id.product_variant_id
if line_qty is False:
line_qty = bom.product_qty
if not product_info:
product_info = {}
if simulated_leaves_per_workcenter is False:
simulated_leaves_per_workcenter = defaultdict(list)
company = bom.company_id or self.env.company
current_quantity = line_qty
if bom_line:
current_quantity = bom_line.product_uom_id._compute_quantity(line_qty, bom.product_uom_id) or 0
prod_cost = 0
attachment_ids = []
has_attachments = False
if not is_minimized:
if product:
prod_cost = product.uom_id._compute_price(product.with_company(company).standard_price, bom.product_uom_id) * current_quantity
attachment_ids = self.env['mrp.document'].search(['|', '&', ('res_model', '=', 'product.product'),
has_attachments = self.env['product.document'].search_count(['&', '&', ('attached_on_mrp', '=', 'bom'), ('active', '=', 't'), '|', '&', ('res_model', '=', 'product.product'),
('res_id', '=', product.id), '&', ('res_model', '=', 'product.template'),
('res_id', '=', product.product_tmpl_id.id)]).ids
('res_id', '=', product.product_tmpl_id.id)], limit=1) > 0
else:
# Use the product template instead of the variant
prod_cost = bom.product_tmpl_id.uom_id._compute_price(bom.product_tmpl_id.with_company(company).standard_price, bom.product_uom_id) * current_quantity
attachment_ids = self.env['mrp.document'].search([('res_model', '=', 'product.template'), ('res_id', '=', bom.product_tmpl_id.id)]).ids
has_attachments = self.env['product.document'].search_count(['&', '&', ('attached_on_mrp', '=', 'bom'), ('active', '=', 't'),
'&', ('res_model', '=', 'product.template'), ('res_id', '=', bom.product_tmpl_id.id)], limit=1) > 0
key = product.id
bom_key = bom.id
qty_product_uom = bom.product_uom_id._compute_quantity(current_quantity, product.uom_id or bom.product_tmpl_id.uom_id)
self._update_product_info(product, bom_key, product_info, warehouse, qty_product_uom, bom=bom, parent_bom=parent_bom)
self._update_product_info(product, bom_key, product_info, warehouse, qty_product_uom, bom=bom, parent_bom=parent_bom, parent_product=parent_product)
route_info = product_info[key].get(bom_key, {})
quantities_info = {}
if not ignore_stock:
# Useless to compute quantities_info if it's not going to be used later on
quantities_info = self._get_quantities_info(product, bom.product_uom_id, parent_bom, product_info)
quantities_info = self._get_quantities_info(product, bom.product_uom_id, product_info, parent_bom, parent_product)
bom_report_line = {
'index': index,
@ -236,9 +214,12 @@ class ReportBomStructure(models.AbstractModel):
'bom_id': bom and bom.id or False,
'bom_code': bom and bom.code or False,
'type': 'bom',
'is_storable': product.is_storable,
'quantity': current_quantity,
'quantity_available': quantities_info.get('free_qty') or 0,
'quantity_on_hand': quantities_info.get('on_hand_qty') or 0,
'quantity_forecasted': quantities_info.get('forecasted_qty') or 0,
'free_to_manufacture_qty': quantities_info.get('free_to_manufacture_qty') or 0,
'base_bom_line_qty': bom_line.product_qty if bom_line else False, # bom_line isn't defined only for the top-level product
'name': product.display_name or bom.product_tmpl_id.display_name,
'uom': bom.product_uom_id if bom else product.uom_id,
@ -247,29 +228,21 @@ class ReportBomStructure(models.AbstractModel):
'route_name': route_info.get('route_name', ''),
'route_detail': route_info.get('route_detail', ''),
'route_alert': route_info.get('route_alert', False),
'lead_time': route_info.get('lead_time', False),
'currency': company.currency_id,
'currency_id': company.currency_id.id,
'product': product,
'product_id': product.id,
'product_template_id': product.product_tmpl_id.id,
'link_id': (product.id if product.product_variant_count > 1 else product.product_tmpl_id.id) or bom.product_tmpl_id.id,
'link_model': 'product.product' if product.product_variant_count > 1 else 'product.template',
'code': bom and bom.display_name or '',
'prod_cost': prod_cost,
'bom_cost': 0,
'level': level or 0,
'attachment_ids': attachment_ids,
'has_attachments': has_attachments,
'phantom_bom': bom.type == 'phantom',
'parent_id': parent_bom and parent_bom.id or False,
}
if not is_minimized:
operations = self._get_operation_line(product, bom, float_round(current_quantity, precision_rounding=1, rounding_method='UP'), level + 1, index)
bom_report_line['operations'] = operations
bom_report_line['operations_cost'] = sum([op['bom_cost'] for op in operations])
bom_report_line['operations_time'] = sum([op['quantity'] for op in operations])
bom_report_line['bom_cost'] += bom_report_line['operations_cost']
components = []
no_bom_lines = self.env['mrp.bom.line']
line_quantities = {}
@ -282,27 +255,67 @@ class ReportBomStructure(models.AbstractModel):
no_bom_lines |= line
# Update product_info for all the components before computing closest forecasted.
qty_product_uom = line.product_uom_id._compute_quantity(line_quantity, line.product_id.uom_id)
self.with_context(parent_product_id=product.id)._update_product_info(line.product_id, bom.id, product_info, warehouse, qty_product_uom, bom=False, parent_bom=bom)
components_closest_forecasted = self.with_context(parent_product_id=product.id)._get_components_closest_forecasted(no_bom_lines, line_quantities, bom, product_info, ignore_stock)
self._update_product_info(line.product_id, bom.id, product_info, warehouse, qty_product_uom, bom=False, parent_bom=bom, parent_product=product)
components_closest_forecasted = self._get_components_closest_forecasted(no_bom_lines, line_quantities, bom, product_info, product, ignore_stock)
for component_index, line in enumerate(bom.bom_line_ids):
new_index = f"{index}{component_index}"
if product and line._skip_bom_line(product):
continue
line_quantity = line_quantities.get(line.id, 0.0)
if line.child_bom_id:
component = self.with_context(parent_product_id=product.id)._get_bom_data(line.child_bom_id, warehouse, line.product_id, line_quantity, bom_line=line, level=level + 1, parent_bom=bom,
index=new_index, product_info=product_info, ignore_stock=ignore_stock)
component = self._get_bom_data(line.child_bom_id, warehouse, line.product_id, line_quantity, bom_line=line, level=level + 1, parent_bom=bom,
parent_product=product, index=new_index, product_info=product_info, ignore_stock=ignore_stock,
simulated_leaves_per_workcenter=simulated_leaves_per_workcenter)
else:
component = self.with_context(
parent_product_id=product.id,
components_closest_forecasted=components_closest_forecasted,
)._get_component_data(bom, warehouse, line, line_quantity, level + 1, new_index, product_info, ignore_stock)
components.append(component)
)._get_component_data(bom, product, warehouse, line, line_quantity, level + 1, new_index, product_info, ignore_stock)
for component_bom in components:
if component['product_id'] == component_bom['product_id'] and component['uom'].id == component_bom['uom'].id:
self._merge_components(component_bom, component)
break
else:
components.append(component)
bom_report_line['bom_cost'] += component['bom_cost']
for component in components:
if component['is_storable']:
if missing_qty := max(component['quantity'] - component['quantity_forecasted'], 0):
missing_qty = float_repr(missing_qty, self.env['decimal.precision'].precision_get('Product Unit'))
route_name = component['route_name'] or _('Order')
component['status'] = _("%(qty)s To %(route)s", qty=missing_qty, route=route_name)
bom_report_line['components'] = components
bom_report_line['producible_qty'] = self._compute_current_production_capacity(bom_report_line)
availabilities = self._get_availabilities(product, current_quantity, product_info, bom_key, quantities_info, level, ignore_stock, components, report_line=bom_report_line)
# in case of subcontracting, lead_time will be calculated with components availability delay
bom_report_line['lead_time'] = route_info.get('lead_time', False)
bom_report_line['manufacture_delay'] = route_info.get('manufacture_delay', False)
bom_report_line.update(availabilities)
if level == 0:
if bom_report_line['producible_qty'] > 0:
bom_report_line['status'] = _("%(qty)s Ready To Produce", qty=bom_report_line['producible_qty'])
else:
bom_report_line['status'] = _("No Ready To Produce")
elif missing_qty := max(bom_report_line['quantity'] - bom_report_line['quantity_available'], 0):
missing_qty = float_repr(missing_qty, self.env['decimal.precision'].precision_get('Product Unit'))
route_name = bom_report_line['route_name'] or _('Order')
bom_report_line['status'] = _("%(qty)s To %(route)s", qty=missing_qty, route=route_name)
if not is_minimized:
operations = self._get_operation_line(product, bom, float_round(current_quantity, precision_digits=self.env['decimal.precision'].precision_get('Product Unit'), rounding_method='UP'), level + 1, index, bom_report_line, simulated_leaves_per_workcenter)
bom_report_line['operations'] = operations
bom_report_line['operations_cost'] = sum(op['bom_cost'] for op in operations)
bom_report_line['operations_time'] = sum(op['quantity'] for op in operations)
bom_report_line['operations_delay'] = max((op['availability_delay'] for op in operations), default=0)
if 'simulated' in bom_report_line:
bom_report_line['availability_state'] = 'estimated'
max_component_delay = bom_report_line['max_component_delay']
bom_report_line['availability_delay'] = max_component_delay + max(bom.produce_delay, bom_report_line['operations_delay'])
bom_report_line['availability_display'] = self._format_date_display(bom_report_line['availability_state'], bom_report_line['availability_delay'])
bom_report_line['bom_cost'] += bom_report_line['operations_cost']
byproducts, byproduct_cost_portion = self._get_byproducts_lines(product, bom, current_quantity, level + 1, bom_report_line['bom_cost'], index)
bom_report_line['byproducts'] = byproducts
bom_report_line['cost_share'] = float_round(1 - byproduct_cost_portion, precision_rounding=0.0001)
@ -310,8 +323,7 @@ class ReportBomStructure(models.AbstractModel):
bom_report_line['byproducts_total'] = sum(byproduct['quantity'] for byproduct in byproducts)
bom_report_line['bom_cost'] *= bom_report_line['cost_share']
availabilities = self._get_availabilities(product, current_quantity, product_info, bom_key, quantities_info, level, ignore_stock, components)
bom_report_line.update(availabilities)
bom_report_line['foldable'] = len(bom.operation_ids) > 0 or (len(bom_report_line['components']) > 0 and level > 0) or any(component.get('foldable', False) for component in bom_report_line['components'])
if level == 0:
# Gives a unique key for the first line that indicates if product is ready for production right now.
@ -319,7 +331,7 @@ class ReportBomStructure(models.AbstractModel):
return bom_report_line
@api.model
def _get_component_data(self, parent_bom, warehouse, bom_line, line_quantity, level, index, product_info, ignore_stock=False):
def _get_component_data(self, parent_bom, parent_product, warehouse, bom_line, line_quantity, level, index, product_info, ignore_stock=False):
company = parent_bom.company_id or self.env.company
price = bom_line.product_id.uom_id._compute_price(bom_line.product_id.with_company(company).standard_price, bom_line.product_uom_id) * line_quantity
rounded_price = company.currency_id.round(price)
@ -331,13 +343,13 @@ class ReportBomStructure(models.AbstractModel):
quantities_info = {}
if not ignore_stock:
# Useless to compute quantities_info if it's not going to be used later on
quantities_info = self._get_quantities_info(bom_line.product_id, bom_line.product_uom_id, parent_bom, product_info)
quantities_info = self._get_quantities_info(bom_line.product_id, bom_line.product_uom_id, product_info, parent_bom, parent_product)
availabilities = self._get_availabilities(bom_line.product_id, line_quantity, product_info, bom_key, quantities_info, level, ignore_stock, bom_line=bom_line)
attachment_ids = []
has_attachments = False
if not self.env.context.get('minimized', False):
attachment_ids = self.env['mrp.document'].search(['|', '&', ('res_model', '=', 'product.product'), ('res_id', '=', bom_line.product_id.id),
'&', ('res_model', '=', 'product.template'), ('res_id', '=', bom_line.product_id.product_tmpl_id.id)]).ids
has_attachments = self.env['product.document'].search_count(['&', ('attached_on_mrp', '=', 'bom'), '|', '&', ('res_model', '=', 'product.product'), ('res_id', '=', bom_line.product_id.id),
'&', ('res_model', '=', 'product.template'), ('res_id', '=', bom_line.product_id.product_tmpl_id.id)]) > 0
return {
'type': 'component',
@ -345,25 +357,29 @@ class ReportBomStructure(models.AbstractModel):
'bom_id': False,
'product': bom_line.product_id,
'product_id': bom_line.product_id.id,
'product_template_id': bom_line.product_tmpl_id.id,
'link_id': bom_line.product_id.id if bom_line.product_id.product_variant_count > 1 else bom_line.product_id.product_tmpl_id.id,
'link_model': 'product.product' if bom_line.product_id.product_variant_count > 1 else 'product.template',
'name': bom_line.product_id.display_name,
'code': '',
'currency': company.currency_id,
'currency_id': company.currency_id.id,
'is_storable': bom_line.product_id.is_storable,
'quantity': line_quantity,
'quantity_available': quantities_info.get('free_qty', 0),
'quantity_on_hand': quantities_info.get('on_hand_qty', 0),
'quantity_forecasted': quantities_info.get('forecasted_qty', 0),
'free_to_manufacture_qty': quantities_info.get('free_to_manufacture_qty', 0),
'base_bom_line_qty': bom_line.product_qty,
'uom': bom_line.product_uom_id,
'uom_name': bom_line.product_uom_id.name,
'prod_cost': rounded_price,
'bom_cost': rounded_price,
'route_type': route_info.get('route_type', ''),
'route_name': route_info.get('route_name', ''),
'route_detail': route_info.get('route_detail', ''),
'route_alert': route_info.get('route_alert', False),
'lead_time': route_info.get('lead_time', False),
'manufacture_delay': route_info.get('manufacture_delay', False),
'stock_avail_state': availabilities['stock_avail_state'],
'resupply_avail_delay': availabilities['resupply_avail_delay'],
'availability_display': availabilities['availability_display'],
@ -371,31 +387,31 @@ class ReportBomStructure(models.AbstractModel):
'availability_delay': availabilities['availability_delay'],
'parent_id': parent_bom.id,
'level': level or 0,
'attachment_ids': attachment_ids,
'has_attachments': has_attachments,
}
@api.model
def _get_quantities_info(self, product, bom_uom, parent_bom, product_info):
return {
'free_qty': product.uom_id._compute_quantity(product.free_qty, bom_uom) if product.detailed_type == 'product' else False,
'on_hand_qty': product.uom_id._compute_quantity(product.qty_available, bom_uom) if product.detailed_type == 'product' else False,
def _get_quantities_info(self, product, bom_uom, product_info, parent_bom=False, parent_product=False):
quantities_info = {
'free_qty': max(product.uom_id._compute_quantity(product.free_qty, bom_uom), 0) if product.is_storable else 0,
'on_hand_qty': product.uom_id._compute_quantity(product.qty_available, bom_uom) if product.is_storable else 0,
'forecasted_qty': product.uom_id._compute_quantity(product.virtual_available, bom_uom) if product.is_storable else 0,
'stock_loc': 'in_stock',
}
quantities_info['free_to_manufacture_qty'] = quantities_info['free_qty']
return quantities_info
@api.model
def _update_product_info(self, product, bom_key, product_info, warehouse, quantity, bom, parent_bom):
def _update_product_info(self, product, bom_key, product_info, warehouse, quantity, bom, parent_bom, parent_product):
key = product.id
if key not in product_info:
product_info[key] = {'consumptions': {'in_stock': 0}}
if not product_info[key].get(bom_key):
product_info[key][bom_key] = self.with_context(
product_info=product_info, parent_bom=parent_bom
)._get_resupply_route_info(warehouse, product, quantity, bom)
product_info[key][bom_key] = self._get_resupply_route_info(warehouse, product, quantity, product_info, bom, parent_bom, parent_product)
elif product_info[key][bom_key].get('route_alert'):
# Need more quantity than a single line, might change with additional quantity
product_info[key][bom_key] = self.with_context(
product_info=product_info, parent_bom=parent_bom
)._get_resupply_route_info(warehouse, product, quantity + product_info[key][bom_key].get('qty_checked'), bom)
product_info[key][bom_key] = self._get_resupply_route_info(
warehouse, product, quantity + product_info[key][bom_key].get('qty_checked'), product_info, bom, parent_bom, parent_product)
@api.model
def _get_byproducts_lines(self, product, bom, bom_quantity, level, total, index):
@ -420,7 +436,6 @@ class ReportBomStructure(models.AbstractModel):
'name': byproduct.product_id.display_name,
'quantity': line_quantity,
'uom_name': byproduct.product_uom_id.name,
'prod_cost': company.currency_id.round(price),
'parent_id': bom.id,
'level': level or 0,
'bom_cost': company.currency_id.round(total * cost_share),
@ -430,24 +445,37 @@ class ReportBomStructure(models.AbstractModel):
return byproducts, byproduct_cost_portion
@api.model
def _get_operation_cost(self, duration, operation):
return (duration / 60.0) * operation.workcenter_id.costs_hour
@api.model
def _get_operation_line(self, product, bom, qty, level, index):
def _get_operation_line(self, product, bom, qty, level, index, bom_report_line, simulated_leaves_per_workcenter):
operations = []
total = 0.0
qty = bom.product_uom_id._compute_quantity(qty, bom.product_tmpl_id.uom_id)
company = bom.company_id or self.env.company
operations_planning = {}
if bom_report_line['availability_state'] in ['unavailable', 'estimated'] and bom.operation_ids:
qty_requested = bom.product_uom_id._compute_quantity(qty, bom.product_tmpl_id.uom_id)
qty_to_produce = bom.product_tmpl_id.uom_id._compute_quantity(max(0, qty_requested - (product.virtual_available if level > 1 else 0)), bom.product_uom_id)
if not (product or bom.product_tmpl_id).uom_id.is_zero(qty_to_produce):
max_component_delay = 0
for component in bom_report_line['components']:
line_delay = component.get('availability_delay', 0)
max_component_delay = max(max_component_delay, line_delay)
date_today = self.env.context.get('from_date', fields.Date.today()) + timedelta(days=max_component_delay)
operations_planning = self._simulate_bom_planning(bom, product, datetime.combine(date_today, time.min), qty_to_produce, simulated_leaves_per_workcenter=simulated_leaves_per_workcenter)
bom_report_line['simulated'] = True
bom_report_line['max_component_delay'] = max_component_delay
operation_index = 0
for operation in bom.operation_ids:
if not product or operation._skip_operation_line(product):
continue
capacity = operation.workcenter_id._get_capacity(product)
operation_cycle = float_round(qty / capacity, precision_rounding=1, rounding_method='UP')
duration_expected = (operation_cycle * operation.time_cycle * 100.0 / operation.workcenter_id.time_efficiency) + \
operation.workcenter_id._get_expected_duration(product)
total = self._get_operation_cost(duration_expected, operation)
op = operation.with_context(product=product, quantity=qty)
duration_expected = op.time_total
bom_cost = self.env.company.currency_id.round(op.cost)
if planning := operations_planning.get(operation, None):
availability_state = 'estimated'
availability_delay = (planning['date_finished'].date() - date_today).days
availability_display = _('Estimated %s', format_date(self.env, planning['date_finished'])) + (" [" + planning['workcenter'].name + "]" if planning['workcenter'] != operation.workcenter_id else "")
else:
availability_state = 'available'
availability_delay = 0
availability_display = ''
operations.append({
'type': 'operation',
'index': f"{index}{operation_index}",
@ -458,9 +486,12 @@ class ReportBomStructure(models.AbstractModel):
'name': operation.name + ' - ' + operation.workcenter_id.name,
'uom_name': _("Minutes"),
'quantity': duration_expected,
'bom_cost': self.env.company.currency_id.round(total),
'bom_cost': bom_cost,
'currency_id': company.currency_id.id,
'model': 'mrp.routing.workcenter',
'availability_state': availability_state,
'availability_delay': availability_delay,
'availability_display': availability_display,
})
operation_index += 1
return operations
@ -476,8 +507,8 @@ class ReportBomStructure(models.AbstractModel):
else:
product = bom.product_id or bom.product_tmpl_id.product_variant_id or bom.product_tmpl_id.with_context(active_test=False).product_variant_ids[:1]
if self.env.context.get('warehouse'):
warehouse = self.env['stock.warehouse'].browse(self.env.context.get('warehouse'))
if self.env.context.get('warehouse_id'):
warehouse = self.env['stock.warehouse'].browse(self.env.context.get('warehouse_id'))
else:
warehouse = self.env['stock.warehouse'].browse(self.get_warehouses()[0]['id'])
@ -499,22 +530,24 @@ class ReportBomStructure(models.AbstractModel):
'bom_id': bom_line['bom_id'],
'name': bom_line['name'],
'type': bom_line['type'],
'is_storable': bom_line['is_storable'],
'quantity': bom_line['quantity'],
'quantity_available': bom_line['quantity_available'],
'quantity_on_hand': bom_line['quantity_on_hand'],
'producible_qty': bom_line.get('producible_qty', False),
'uom': bom_line['uom_name'],
'prod_cost': bom_line['prod_cost'],
'bom_cost': bom_line['bom_cost'],
'route_name': bom_line['route_name'],
'route_detail': bom_line['route_detail'],
'route_alert': bom_line.get('route_alert', False),
'lead_time': bom_line['lead_time'],
'manufacture_delay': bom_line['manufacture_delay'],
'level': bom_line['level'],
'code': bom_line['code'],
'availability_state': bom_line['availability_state'],
'availability_display': bom_line['availability_display'],
'visible': line_visible,
'status': bom_line.get('status', ""),
})
if bom_line.get('components'):
lines += self._get_bom_array_lines(bom_line, level + 1, unfolded_ids, unfolded, line_visible and line_unfolded)
@ -538,6 +571,9 @@ class ReportBomStructure(models.AbstractModel):
'uom': _('minutes'),
'bom_cost': operation['bom_cost'],
'level': level + 1,
'availability_state': operation['availability_state'],
'availability_delay': operation['availability_delay'],
'availability_display': operation['availability_display'],
'visible': operations_unfolded,
})
if data['byproducts']:
@ -557,7 +593,6 @@ class ReportBomStructure(models.AbstractModel):
'type': 'byproduct',
'quantity': byproduct['quantity'],
'uom': byproduct['uom_name'],
'prod_cost': byproduct['prod_cost'],
'bom_cost': byproduct['bom_cost'],
'level': level + 1,
'visible': byproducts_unfolded,
@ -565,30 +600,27 @@ class ReportBomStructure(models.AbstractModel):
return lines
@api.model
def _get_resupply_route_info(self, warehouse, product, quantity, bom=False):
def _get_resupply_route_info(self, warehouse, product, quantity, product_info, bom=False, parent_bom=False, parent_product=False):
found_rules = []
if self._need_special_rules(self.env.context.get('product_info'), self.env.context.get('parent_bom'), self.env.context.get('parent_product_id')):
found_rules = self._find_special_rules(product, self.env.context.get('product_info'), self.env.context.get('parent_bom'), self.env.context.get('parent_product_id'))
if found_rules and not self._is_resupply_rules(found_rules, bom):
# We only want to show the effective resupply (i.e. a form of manufacture or buy)
found_rules = []
if self._need_special_rules(product_info, parent_bom, parent_product):
found_rules = self._find_special_rules(product, product_info, bom, parent_bom, parent_product)
if not found_rules:
found_rules = product._get_rules_from_location(warehouse.lot_stock_id)
if not found_rules:
return {}
rules_delay = sum(rule.delay for rule in found_rules)
return self._format_route_info(found_rules, rules_delay, warehouse, product, bom, quantity)
return self.with_context(parent_bom=parent_bom)._format_route_info(found_rules, rules_delay, warehouse, product, bom, quantity)
@api.model
def _is_resupply_rules(self, rules, bom):
return bom and any(rule.action == 'manufacture' for rule in rules)
@api.model
def _need_special_rules(self, product_info, parent_bom=False, parent_product_id=False):
def _need_special_rules(self, product_info, parent_bom=False, parent_product=False):
return False
@api.model
def _find_special_rules(self, product, product_info, parent_bom=False, parent_product_id=False):
def _find_special_rules(self, product, product_info, current_bom=False, parent_bom=False, parent_product=False):
return False
@api.model
@ -599,18 +631,18 @@ class ReportBomStructure(models.AbstractModel):
wh_manufacture_rules = product._get_rules_from_location(product.property_stock_production, route_ids=warehouse.route_ids)
wh_manufacture_rules -= rules
rules_delay += sum(rule.delay for rule in wh_manufacture_rules)
manufacturing_lead = bom.company_id.manufacturing_lead if bom and bom.company_id else 0
return {
'route_type': 'manufacture',
'route_name': manufacture_rules[0].route_id.display_name,
'route_detail': bom.display_name,
'lead_time': product.produce_delay + rules_delay + manufacturing_lead,
'manufacture_delay': product.produce_delay + rules_delay + manufacturing_lead,
'lead_time': bom.produce_delay + rules_delay + bom.days_to_prepare_mo,
'manufacture_delay': bom.produce_delay + rules_delay,
'bom': bom,
}
return {}
@api.model
def _get_availabilities(self, product, quantity, product_info, bom_key, quantities_info, level, ignore_stock=False, components=False, bom_line=None):
def _get_availabilities(self, product, quantity, product_info, bom_key, quantities_info, level, ignore_stock=False, components=False, bom_line=None, report_line=False):
# Get availabilities according to stock (today & forecasted).
stock_state, stock_delay = ('unavailable', False)
if not ignore_stock:
@ -620,11 +652,15 @@ class ReportBomStructure(models.AbstractModel):
components = components or []
route_info = product_info[product.id].get(bom_key)
resupply_state, resupply_delay = ('unavailable', False)
if product and product.detailed_type != 'product':
if product and not product.is_storable:
resupply_state, resupply_delay = ('available', 0)
elif route_info:
resupply_state, resupply_delay = self._get_resupply_availability(route_info, components)
if resupply_state == "unavailable" and route_info == {} and components and report_line and report_line['phantom_bom']:
val = self._get_last_availability(report_line)
return val
base = {
'resupply_avail_delay': resupply_delay,
'stock_avail_state': stock_state,
@ -650,26 +686,25 @@ class ReportBomStructure(models.AbstractModel):
return ('available', 0)
if closest_forecasted == date.max:
return ('unavailable', False)
date_today = self.env.context.get('from_date', fields.date.today())
if product and product.detailed_type != 'product':
date_today = self.env.context.get('from_date', fields.Date.today())
if product and not product.is_storable:
return ('available', 0)
stock_loc = quantities_info['stock_loc']
product_info[product.id]['consumptions'][stock_loc] += quantity
# Check if product is already in stock with enough quantity
if product and float_compare(product_info[product.id]['consumptions'][stock_loc], quantities_info['free_qty'], precision_rounding=product.uom_id.rounding) <= 0:
if product and product.uom_id.compare(product_info[product.id]['consumptions'][stock_loc], quantities_info['free_qty']) <= 0:
return ('available', 0)
# No need to check forecast if the product isn't located in our stock
if stock_loc == 'in_stock':
domain = [('state', '=', 'forecast'), ('date', '>=', date_today), ('product_id', '=', product.id), ('product_qty', '>=', product_info[product.id]['consumptions'][stock_loc])]
if self.env.context.get('warehouse'):
domain.append(('warehouse_id', '=', self.env.context.get('warehouse')))
if self.env.context.get('warehouse_id'):
domain.append(('warehouse_id', '=', self.env.context.get('warehouse_id')))
# Seek the closest date in the forecast report where consummed quantity >= forecasted quantity
if not closest_forecasted:
closest_forecasted = self.env['report.stock.quantity']._read_group(domain, ['min_date:min(date)', 'product_id'], ['product_id'])
closest_forecasted = closest_forecasted and closest_forecasted[0]['min_date']
[closest_forecasted] = self.env['report.stock.quantity']._read_group(domain, aggregates=['date:min'])[0]
if closest_forecasted:
days_to_forecast = (closest_forecasted - date_today).days
return ('expected', days_to_forecast)
@ -698,7 +733,7 @@ class ReportBomStructure(models.AbstractModel):
@api.model
def _format_date_display(self, state, delay):
date_today = self.env.context.get('from_date', fields.date.today())
date_today = self.env.context.get('from_date', fields.Date.today())
if state == 'available':
return _('Available')
if state == 'unavailable':
@ -711,4 +746,112 @@ class ReportBomStructure(models.AbstractModel):
@api.model
def _has_attachments(self, data):
return data['attachment_ids'] or any(self._has_attachments(component) for component in data.get('components', []))
return data['has_attachments'] or any(self._has_attachments(component) for component in data.get('components', []))
def _merge_components(self, component_1, component_2):
qty = component_2['quantity']
component_1["quantity"] = component_1["quantity"] + qty
component_1["base_bom_line_qty"] = component_1["quantity"] + qty
component_1["bom_cost"] = component_1["bom_cost"] + component_2["bom_cost"]
if component_2.get('availability_delay') is False or component_2.get('availability_delay') >= component_1.get('availability_delay'):
component_1.update(self._format_availability(component_2))
if not component_1.get("components"):
return
for index in range(len(component_1.get("components"))):
self._merge_components(component_1["components"][index], component_2["components"][index])
def _get_last_availability(self, report_line):
delay = 0
component_max_delay = False
for component in report_line["components"]:
if component["availability_delay"] is False:
component_max_delay = component
break
elif component["availability_delay"] >= delay:
component_max_delay = component
delay = component["availability_delay"]
return self._format_availability(component_max_delay)
def _format_availability(self, component):
return {
'resupply_avail_delay': component['resupply_avail_delay'],
'stock_avail_state': component['stock_avail_state'],
'availability_display': component['availability_display'],
'availability_state': component['availability_state'],
'availability_delay': component['availability_delay'],
}
def _simulate_bom_planning(self, bom, product, start_date, quantity, simulated_leaves_per_workcenter=False):
""" Simulate planning of all the operations depending on the workcenters work schedule.
(see '_plan_workorders' & '_link_workorders_and_moves')
"""
bom.ensure_one()
if not bom.operation_ids:
return {}
if not product:
product = bom.product_id or bom.product_tmpl_id.product_variant_id
planning_per_operation = {}
if simulated_leaves_per_workcenter is False:
simulated_leaves_per_workcenter = defaultdict(list)
if bom.allow_operation_dependencies:
final_operations = bom.operation_ids.filtered(lambda o: not o.needed_by_operation_ids)
for operation in final_operations:
if operation._skip_operation_line(product):
continue
self._simulate_operation_planning(operation, product, start_date, quantity, planning_per_operation, simulated_leaves_per_workcenter)
else:
for operation in bom.operation_ids:
if operation._skip_operation_line(product):
continue
self._simulate_operation_planning(operation, product, start_date, quantity, planning_per_operation, simulated_leaves_per_workcenter)
start_date = planning_per_operation[operation]['date_finished']
return planning_per_operation
def _simulate_operation_planning(self, operation, product, start_date, quantity, planning_per_operation=False, simulated_leaves_per_workcenter=False):
""" Simulate planning of an operation depending on its workcenter/alternatives work schedule.
(see '_plan_workorder')
"""
operation.ensure_one()
if planning_per_operation is False:
planning_per_operation = {}
if simulated_leaves_per_workcenter is False:
simulated_leaves_per_workcenter = defaultdict(list)
# Plan operation after its predecessors
date_start = max(start_date, datetime.now())
for op in operation.blocked_by_operation_ids:
if op._skip_operation_line(product):
continue
if op not in planning_per_operation:
self._simulate_operation_planning(op, product, start_date, quantity, planning_per_operation, simulated_leaves_per_workcenter)
date_start = max(date_start, planning_per_operation[op]['date_finished'])
# Consider workcenter and alternatives
workcenters = operation.workcenter_id | operation.workcenter_id.alternative_workcenter_ids
best_date_finished = datetime.max
best_date_start = best_workcenter = best_duration_expected = None
for workcenter in workcenters:
if not workcenter.resource_calendar_id:
raise UserError(_('There is no defined calendar on workcenter %s.', workcenter.name))
# Compute theoretical duration
duration_expected = operation.with_context(product=product, quantity=quantity, workcenter=workcenter).time_total
# Try to plan on workcenter
from_date, to_date = workcenter._get_first_available_slot(date_start, duration_expected, extra_leaves_slots=simulated_leaves_per_workcenter[workcenter])
# If the workcenter is unavailable, try planning on the next one
if not from_date:
continue
# Check if this workcenter is better than the previous ones
if to_date and to_date < best_date_finished:
best_date_start = from_date
best_date_finished = to_date
best_workcenter = workcenter
best_duration_expected = duration_expected
# If none of the workcenter are available, raise
if best_date_finished == datetime.max:
raise UserError(_('Impossible to plan. Please check the workcenter availabilities.'))
planning_per_operation[operation] = {
'date_start': best_date_start,
'date_finished': best_date_finished,
'workcenter': best_workcenter,
'duration_expected': best_duration_expected,
}
simulated_leaves_per_workcenter[best_workcenter].append((best_date_start, best_date_finished))
return planning_per_operation