[REF] mrp: change BoM Structure&Cost into Overview

Changes "Structure & Cost" report to "Overview", as the goal for this
report is improved.

The point is to have on a single report all components and sub-assemblies
that make it up, in parallel with the route and lead times associated to
each. This way it becomes easier to identify issues, such as:
- Missing routes.
- Longer lead times on a specific sub-assembly.
- Unavailable component in the foreseable future.
- ...

Also did the conversion to OWL, changing drastically the structure of
the report.

Task-2628323

Part-of: odoo/odoo#93194
This commit is contained in:
clesgow
2022-08-26 17:16:41 +02:00
parent a6791e3b95
commit 02cd750da6
25 changed files with 1777 additions and 829 deletions
+3 -15
View File
@@ -58,26 +58,14 @@
'uninstall_hook': 'uninstall_hook',
'assets': {
'web.assets_backend': [
'mrp/static/src/scss/mrp_workorder_kanban.scss',
'mrp/static/src/scss/mrp_fields.scss',
'mrp/static/src/scss/mrp_gantt.scss',
'mrp/static/src/scss/mrp_document_kanban_view.scss',
'mrp/static/src/js/mrp.js',
'mrp/static/src/js/mrp_bom_report.js',
'mrp/static/src/js/mrp_workorder_popover.js',
'mrp/static/src/js/mrp_documents_controller_mixin.js',
'mrp/static/src/js/mrp_documents_document_viewer.js',
'mrp/static/src/js/mrp_documents_kanban_controller.js',
'mrp/static/src/js/mrp_documents_kanban_record.js',
'mrp/static/src/js/mrp_documents_kanban_renderer.js',
'mrp/static/src/js/mrp_document_kanban_view.js',
'mrp/static/src/js/mrp_should_consume.js',
'mrp/static/src/**/*.js',
'mrp/static/src/**/*.scss',
],
'web.qunit_suite_tests': [
'mrp/static/tests/**/*',
],
'web.assets_qweb': [
'mrp/static/src/xml/*.xml',
'mrp/static/src/**/*.xml',
],
},
'license': 'LGPL-3',
+459 -215
View File
@@ -2,12 +2,51 @@
import json
from odoo import api, models, _
from odoo.tools import float_round
from odoo import api, fields, models, _
from odoo.tools import float_compare, float_round, format_date
from datetime import timedelta
class ReportBomStructure(models.AbstractModel):
_name = 'report.mrp.report_bom_structure'
_description = 'BOM Structure Report'
_description = 'BOM Overview Report'
@api.model
def get_html(self, bom_id=False, searchQty=1, searchVariant=False):
res = self._get_report_data(bom_id=bom_id, searchQty=searchQty, searchVariant=searchVariant)
res['has_attachments'] = self._has_attachments(res['lines'])
return res
@api.model
def get_warehouses(self):
return self.env['stock.warehouse'].search_read([('company_id', '=', self.env.company.id)], fields=['id', 'name'])
@api.model
def _compute_current_production_capacity(self, bom_data):
# Get the maximum amount producible product of the selected bom given each component's stock levels.
stockable_components = filter(lambda c: c['product'].detailed_type == 'product', bom_data.get('components', []))
producibles = [float_round(comp['quantity_available'] / comp['base_bom_line_qty'], precision_digits=0, rounding_method='DOWN') for comp in stockable_components]
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):
@@ -15,24 +54,14 @@ class ReportBomStructure(models.AbstractModel):
for bom_id in docids:
bom = self.env['mrp.bom'].browse(bom_id)
variant = data.get('variant')
candidates = variant and self.env['product.product'].browse(variant) or bom.product_id or bom.product_tmpl_id.product_variant_ids
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')))
for product_variant_id in candidates.ids:
if data and data.get('unfolded_ids'):
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['report_type'] = 'pdf'
doc['report_structure'] = data and data.get('report_type') or 'all'
docs.append(doc)
docs.append(self._get_pdf_doc(bom_id, data, quantity, product_variant_id))
if not candidates:
if data and data.get('unfolded_ids'):
doc = self._get_pdf_line(bom_id, qty=quantity, unfolded_ids=set(json.loads(data.get('unfolded_ids'))))
else:
doc = self._get_pdf_line(bom_id, qty=quantity, unfolded=True)
doc['report_type'] = 'pdf'
doc['report_structure'] = data and data.get('report_type') or 'all'
docs.append(doc)
docs.append(self._get_pdf_doc(bom_id, data, quantity))
return {
'doc_ids': docids,
'doc_model': 'mrp.bom',
@@ -40,43 +69,16 @@ class ReportBomStructure(models.AbstractModel):
}
@api.model
def get_html(self, bom_id=False, searchQty=1, searchVariant=False):
res = self._get_report_data(bom_id=bom_id, searchQty=searchQty, searchVariant=searchVariant)
res['lines']['report_type'] = 'html'
res['lines']['report_structure'] = 'all'
res['lines']['has_attachments'] = res['lines']['attachments'] or any(component['attachments'] for component in res['lines']['components'])
res['lines'] = self.env['ir.qweb']._render('mrp.report_mrp_bom', {'data': res['lines']})
return res
@api.model
def get_bom(self, bom_id=False, product_id=False, line_qty=False, line_id=False, level=False):
lines = self._get_bom(bom_id=bom_id, product_id=product_id, line_qty=line_qty, line_id=line_id, level=level)
return self.env['ir.qweb']._render('mrp.report_mrp_bom_line', {'data': lines})
@api.model
def get_operations(self, product_id=False, bom_id=False, qty=0, level=0):
bom = self.env['mrp.bom'].browse(bom_id)
product = self.env['product.product'].browse(product_id)
lines = self._get_operation_line(product, bom, float_round(qty / bom.product_qty, precision_rounding=1, rounding_method='UP'), level)
values = {
'bom_id': bom_id,
'currency': self.env.company.currency_id,
'operations': lines,
'extra_column_count': self._get_extra_column_count()
}
return self.env['ir.qweb']._render('mrp.report_mrp_operation_line', {'data': values})
@api.model
def get_byproducts(self, bom_id=False, qty=0, level=0, total=0):
bom = self.env['mrp.bom'].browse(bom_id)
lines, dummy = self._get_byproducts_lines(bom, qty, level, total)
values = {
'bom_id': bom_id,
'currency': self.env.company.currency_id,
'byproducts': lines,
'extra_column_count': self._get_extra_column_count(),
}
return self.env['ir.qweb']._render('mrp.report_mrp_byproduct_line', {'data': values})
def _get_pdf_doc(self, bom_id, data, quantity, product_variant_id=None):
if data and data.get('unfolded_ids'):
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
return doc
@api.model
def _get_report_data(self, bom_id, searchQty=0, searchVariant=False):
@@ -86,6 +88,11 @@ class ReportBomStructure(models.AbstractModel):
bom_product_variants = {}
bom_uom_name = ''
if searchVariant:
product = self.env['product.product'].browse(int(searchVariant))
else:
product = bom.product_id or bom.product_tmpl_id.product_variant_id
if bom:
bom_uom_name = bom.product_uom_id.name
@@ -94,7 +101,14 @@ class ReportBomStructure(models.AbstractModel):
for variant in bom.product_tmpl_id.product_variant_ids:
bom_product_variants[variant.id] = variant.display_name
lines = self._get_bom(bom_id, product_id=searchVariant, line_qty=bom_quantity, level=1)
if self.env.context.get('warehouse'):
warehouse = self.env['stock.warehouse'].browse(self.env.context.get('warehouse'))
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,
@@ -102,121 +116,232 @@ class ReportBomStructure(models.AbstractModel):
'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'),
'extra_column_count': self._get_extra_column_count()
}
def _get_bom(self, bom_id=False, product_id=False, line_qty=False, line_id=False, level=False):
bom = self.env['mrp.bom'].browse(bom_id)
company = bom.company_id or self.env.company
bom_quantity = line_qty
if line_id:
current_line = self.env['mrp.bom.line'].browse(int(line_id))
bom_quantity = current_line.product_uom_id._compute_quantity(line_qty, bom.product_uom_id) or 0
# Display bom components for current selected product variant
if product_id:
product = self.env['product.product'].browse(int(product_id))
else:
@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):
""" 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.
- 'from_date': Gives a single value for 'today' across the functions, as well as using this date in products quantity computes.
"""
is_minimized = self.env.context.get('minimized', False)
if not product:
product = bom.product_id or bom.product_tmpl_id.product_variant_id
if product:
price = product.uom_id._compute_price(product.with_company(company).standard_price, bom.product_uom_id) * bom_quantity
attachments = self.env['mrp.document'].search(['|', '&', ('res_model', '=', 'product.product'),
('res_id', '=', product.id), '&', ('res_model', '=', 'product.template'), ('res_id', '=', product.product_tmpl_id.id)])
else:
# Use the product template instead of the variant
price = bom.product_tmpl_id.uom_id._compute_price(bom.product_tmpl_id.with_company(company).standard_price, bom.product_uom_id) * bom_quantity
attachments = self.env['mrp.document'].search([('res_model', '=', 'product.template'), ('res_id', '=', bom.product_tmpl_id.id)])
operations = self._get_operation_line(product, bom, float_round(bom_quantity, precision_rounding=1, rounding_method='UP'), 0)
lines = {
'bom': bom,
'bom_qty': bom_quantity,
'bom_prod_name': product.display_name,
'currency': company.currency_id,
'product': product,
'code': bom and bom.display_name or '',
'price': price,
'total': sum([op['total'] for op in operations]),
'level': level or 0,
'operations': operations,
'operations_cost': sum([op['total'] for op in operations]),
'attachments': attachments,
'operations_time': sum([op['duration_expected'] for op in operations])
}
components, total = self._get_bom_lines(bom, bom_quantity, product, line_id, level)
lines['total'] += total
lines['components'] = components
byproducts, byproduct_cost_portion = self._get_byproducts_lines(bom, bom_quantity, level, lines['total'])
lines['byproducts'] = byproducts
lines['cost_share'] = float_round(1 - byproduct_cost_portion, precision_rounding=0.0001)
lines['bom_cost'] = lines['total'] * lines['cost_share']
lines['byproducts_cost'] = sum(byproduct['bom_cost'] for byproduct in byproducts)
lines['byproducts_total'] = sum(byproduct['product_qty'] for byproduct in byproducts)
lines['extra_column_count'] = self._get_extra_column_count()
return lines
if not line_qty:
line_qty = bom.product_qty
if not product_info:
product_info = {}
key = product.id
if key not in product_info:
product_info[key] = {'consumptions': {'in_stock': 0}}
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 = []
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'),
('res_id', '=', product.id), '&', ('res_model', '=', 'product.template'),
('res_id', '=', product.product_tmpl_id.id)]).ids
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
bom_key = bom.id
if not product_info[key].get(bom_key):
product_info[key][bom_key] = self._get_resupply_route_info(warehouse, product, current_quantity, bom)
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)
bom_report_line = {
'index': index,
'bom': bom,
'bom_id': bom and bom.id or False,
'bom_code': bom and bom.code or False,
'type': 'bom',
'quantity': current_quantity,
'quantity_available': quantities_info.get('free_qty', 0),
'quantity_on_hand': quantities_info.get('on_hand_qty', 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,
'uom': bom.product_uom_id if bom else product.uom_id,
'uom_name': bom.product_uom_id.name if bom else product.uom_id.name,
'route_type': route_info.get('route_type', ''),
'route_name': route_info.get('route_name', ''),
'route_detail': route_info.get('route_detail', ''),
'lead_time': route_info.get('lead_time', False),
'currency': company.currency_id,
'currency_id': company.currency_id.id,
'product': product,
'product_id': product.id,
'link_id': product.id if product.product_variant_count > 1 else product.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,
'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']
def _get_bom_lines(self, bom, bom_quantity, product, line_id, level):
components = []
total = 0
for line in bom.bom_line_ids:
line_quantity = (bom_quantity / (bom.product_qty or 1.0)) * line.product_qty
for component_index, line in enumerate(bom.bom_line_ids):
new_index = f"{index}{component_index}"
if line._skip_bom_line(product):
continue
company = bom.company_id or self.env.company
price = line.product_id.uom_id._compute_price(line.product_id.with_company(company).standard_price, line.product_uom_id) * line_quantity
line_quantity = (current_quantity / (bom.product_qty or 1.0)) * line.product_qty
if line.child_bom_id:
factor = line.product_uom_id._compute_quantity(line_quantity, line.child_bom_id.product_uom_id)
sub_total = self._get_price(line.child_bom_id, factor, line.product_id)
byproduct_cost_share = sum(line.child_bom_id.byproduct_ids.mapped('cost_share'))
if byproduct_cost_share:
sub_total *= float_round(1 - byproduct_cost_share / 100, precision_rounding=0.0001)
component = self._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)
else:
sub_total = price
sub_total = self.env.company.currency_id.round(sub_total)
components.append({
'prod_id': line.product_id.id,
'prod_name': line.product_id.display_name,
'code': line.child_bom_id and line.child_bom_id.display_name or '',
'prod_qty': line_quantity,
'prod_uom': line.product_uom_id.name,
'prod_cost': company.currency_id.round(price),
'parent_id': bom.id,
'line_id': line.id,
'level': level or 0,
'total': sub_total,
'child_bom': line.child_bom_id.id,
'phantom_bom': line.child_bom_id and line.child_bom_id.type == 'phantom' or False,
'attachments': self.env['mrp.document'].search(['|', '&',
('res_model', '=', 'product.product'), ('res_id', '=', line.product_id.id), '&', ('res_model', '=', 'product.template'), ('res_id', '=', line.product_id.product_tmpl_id.id)]),
component = self._get_component_data(bom, warehouse, line, line_quantity, level + 1, new_index, product_info, ignore_stock)
components.append(component)
bom_report_line['bom_cost'] += component['bom_cost']
bom_report_line['components'] = components
bom_report_line['producible_qty'] = self._compute_current_production_capacity(bom_report_line)
})
total += sub_total
return components, total
if not is_minimized:
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)
bom_report_line['byproducts_cost'] = sum(byproduct['bom_cost'] for byproduct in byproducts)
bom_report_line['byproducts_total'] = sum(byproduct['quantity'] for byproduct in byproducts)
bom_report_line['bom_cost'] *= bom_report_line['cost_share']
def _get_byproducts_lines(self, bom, bom_quantity, level, total):
availabilities = self._get_availabilities(product, current_quantity, product_info, bom_key, quantities_info, level, ignore_stock, components)
bom_report_line.update(availabilities)
if level == 0:
# Gives a unique key for the first line that indicates if product is ready for production right now.
bom_report_line['components_available'] = all([c['stock_avail_state'] == 'available' for c in components])
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):
company = parent_bom.company_id or self.env.company
key = bom_line.product_id.id
if key not in product_info:
product_info[key] = {'consumptions': {'in_stock': 0}}
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)
bom_key = 'no_bom'
if not product_info[key].get(bom_key):
product_info[key][bom_key] = self._get_resupply_route_info(warehouse, bom_line.product_id, line_quantity)
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(bom_line.product_id, bom_line.product_uom_id, parent_bom, product_info)
availabilities = self._get_availabilities(bom_line.product_id, line_quantity, product_info, bom_key, quantities_info, level, ignore_stock)
attachment_ids = []
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
return {
'type': 'component',
'index': index,
'bom_id': False,
'product': bom_line.product_id,
'product_id': bom_line.product_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,
'quantity': line_quantity,
'quantity_available': quantities_info.get('free_qty', 0),
'quantity_on_hand': quantities_info.get('on_hand_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', ''),
'lead_time': route_info.get('lead_time', False),
'stock_avail_state': availabilities['stock_avail_state'],
'resupply_avail_delay': availabilities['resupply_avail_delay'],
'availability_display': availabilities['availability_display'],
'availability_state': availabilities['availability_state'],
'availability_delay': availabilities['availability_delay'],
'parent_id': parent_bom.id,
'level': level or 0,
'attachment_ids': attachment_ids,
}
@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,
'stock_loc': 'in_stock',
}
@api.model
def _get_byproducts_lines(self, product, bom, bom_quantity, level, total, index):
byproducts = []
byproduct_cost_portion = 0
company = bom.company_id or self.env.company
byproduct_index = 0
for byproduct in bom.byproduct_ids:
if byproduct._skip_byproduct_line(product):
continue
line_quantity = (bom_quantity / (bom.product_qty or 1.0)) * byproduct.product_qty
cost_share = byproduct.cost_share / 100
byproduct_cost_portion += cost_share
price = byproduct.product_id.uom_id._compute_price(byproduct.product_id.with_company(company).standard_price, byproduct.product_uom_id) * line_quantity
byproducts.append({
'product_id': byproduct.product_id,
'product_name': byproduct.product_id.display_name,
'product_qty': line_quantity,
'product_uom': byproduct.product_uom_id.name,
'product_cost': company.currency_id.round(price),
'id': byproduct.id,
'index': f"{index}{byproduct_index}",
'type': 'byproduct',
'link_id': byproduct.product_id.id if byproduct.product_id.product_variant_count > 1 else byproduct.product_id.product_tmpl_id.id,
'link_model': 'product.product' if byproduct.product_id.product_variant_count > 1 else 'product.template',
'currency_id': company.currency_id.id,
'name': byproduct.product_id.display_name,
'quantity': line_quantity,
'uom': 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),
'cost_share': cost_share,
})
byproduct_index += 1
return byproducts, byproduct_cost_portion
def _get_operation_line(self, product, bom, qty, level):
@api.model
def _get_operation_line(self, product, bom, qty, level, index):
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
operation_index = 0
for operation in bom.operation_ids:
if operation._skip_operation_line(product):
continue
@@ -225,64 +350,75 @@ class ReportBomStructure(models.AbstractModel):
duration_expected = (operation_cycle * operation.time_cycle * 100.0 / operation.workcenter_id.time_efficiency) + (operation.workcenter_id.time_stop + operation.workcenter_id.time_start)
total = ((duration_expected / 60.0) * operation.workcenter_id.costs_hour)
operations.append({
'type': 'operation',
'index': f"{index}{operation_index}",
'level': level or 0,
'operation': operation,
'link_id': operation.id,
'link_model': 'mrp.routing.workcenter',
'name': operation.name + ' - ' + operation.workcenter_id.name,
'duration_expected': duration_expected,
'total': self.env.company.currency_id.round(total),
'uom': _("Minutes"),
'quantity': duration_expected,
'bom_cost': self.env.company.currency_id.round(total),
'currency_id': company.currency_id.id,
'model': 'mrp.routing.workcenter',
})
operation_index += 1
return operations
def _get_price(self, bom, factor, product):
price = 0
if bom.operation_ids:
# routing are defined on a BoM and don't have a concept of quantity.
# It means that the operation time are defined for the quantity on
# the BoM (the user produces a batch of products). E.g the user
# product a batch of 10 units with a 5 minutes operation, the time
# will be the 5 for a quantity between 1-10, then doubled for
# 11-20,...
operation_cycle = float_round(factor, precision_rounding=1, rounding_method='UP')
operations = self._get_operation_line(product, bom, operation_cycle, 0)
price += sum([op['total'] for op in operations])
@api.model
def _get_pdf_line(self, bom_id, product_id=False, qty=1, unfolded_ids=None, unfolded=False):
if unfolded_ids is None:
unfolded_ids = set()
for line in bom.bom_line_ids:
if line._skip_bom_line(product):
continue
if line.child_bom_id:
qty = line.product_uom_id._compute_quantity(line.product_qty * (factor / bom.product_qty), line.child_bom_id.product_uom_id) / line.child_bom_id.product_qty
sub_price = self._get_price(line.child_bom_id, qty, line.product_id)
byproduct_cost_share = sum(line.child_bom_id.byproduct_ids.mapped('cost_share'))
if byproduct_cost_share:
sub_price *= float_round(1 - byproduct_cost_share / 100, precision_rounding=0.0001)
price += sub_price
else:
prod_qty = line.product_qty * factor / bom.product_qty
company = bom.company_id or self.env.company
not_rounded_price = line.product_id.uom_id._compute_price(line.product_id.with_company(company).standard_price, line.product_uom_id) * prod_qty
price += company.currency_id.round(not_rounded_price)
return price
bom = self.env['mrp.bom'].browse(bom_id)
if product_id:
product = self.env['product.product'].browse(int(product_id))
else:
product = bom.product_id or bom.product_tmpl_id.product_variant_id
def _get_sub_lines(self, bom, product_id, line_qty, line_id, level, unfolded_ids, unfolded):
data = self._get_bom(bom_id=bom.id, product_id=product_id, line_qty=line_qty, line_id=line_id, level=level)
if self.env.context.get('warehouse'):
warehouse = self.env['stock.warehouse'].browse(self.env.context.get('warehouse'))
else:
warehouse = self.env['stock.warehouse'].browse(self.get_warehouses()[0]['id'])
level = 1
data = self._get_bom_data(bom, warehouse, product=product, line_qty=qty, level=0)
pdf_lines = self._get_bom_array_lines(data, level, unfolded_ids, unfolded, True)
data['lines'] = pdf_lines
return data
@api.model
def _get_bom_array_lines(self, data, level, unfolded_ids, unfolded, parent_unfolded=True):
bom_lines = data['components']
lines = []
for bom_line in bom_lines:
line_unfolded = ('bom_' + str(bom_line['index'])) in unfolded_ids
line_visible = level == 1 or unfolded or parent_unfolded
lines.append({
'name': bom_line['prod_name'],
'type': 'bom',
'quantity': bom_line['prod_qty'],
'uom': bom_line['prod_uom'],
'bom_id': bom_line['bom_id'],
'name': bom_line['name'],
'type': bom_line['type'],
'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['total'],
'bom_cost': bom_line['bom_cost'],
'route_name': bom_line['route_name'],
'route_detail': bom_line['route_detail'],
'lead_time': bom_line['lead_time'],
'level': bom_line['level'],
'code': bom_line['code'],
'child_bom': bom_line['child_bom'],
'prod_id': bom_line['prod_id']
'availability_state': bom_line['availability_state'],
'availability_display': bom_line['availability_display'],
'visible': line_visible,
})
if bom_line['child_bom'] and (unfolded or bom_line['child_bom'] in unfolded_ids):
line = self.env['mrp.bom.line'].browse(bom_line['line_id'])
lines += (self._get_sub_lines(line.child_bom_id, line.product_id.id, bom_line['prod_qty'], line, level + 1, unfolded_ids, unfolded))
if bom_line.get('components'):
lines += self._get_bom_array_lines(bom_line, level + 1, unfolded_ids, unfolded, line_visible and line_unfolded)
if data['operations']:
lines.append({
'name': _('Operations'),
@@ -291,17 +427,19 @@ class ReportBomStructure(models.AbstractModel):
'uom': _('minutes'),
'bom_cost': data['operations_cost'],
'level': level,
'visible': parent_unfolded,
})
operations_unfolded = unfolded or (parent_unfolded and ('operations_' + str(data['index'])) in unfolded_ids)
for operation in data['operations']:
if unfolded or 'operation-' + str(bom.id) in unfolded_ids:
lines.append({
'name': operation['name'],
'type': 'operation',
'quantity': operation['duration_expected'],
'uom': _('minutes'),
'bom_cost': operation['total'],
'level': level + 1,
})
lines.append({
'name': operation['name'],
'type': 'operation',
'quantity': operation['quantity'],
'uom': _('minutes'),
'bom_cost': operation['bom_cost'],
'level': level + 1,
'visible': operations_unfolded,
})
if data['byproducts']:
lines.append({
'name': _('Byproducts'),
@@ -310,32 +448,138 @@ class ReportBomStructure(models.AbstractModel):
'quantity': data['byproducts_total'],
'bom_cost': data['byproducts_cost'],
'level': level,
'visible': parent_unfolded,
})
byproducts_unfolded = unfolded or (parent_unfolded and ('byproducts_' + str(data['index'])) in unfolded_ids)
for byproduct in data['byproducts']:
if unfolded or 'byproduct-' + str(bom.id) in unfolded_ids:
lines.append({
'name': byproduct['product_name'],
'type': 'byproduct',
'quantity': byproduct['product_qty'],
'uom': byproduct['product_uom'],
'prod_cost': byproduct['product_cost'],
'bom_cost': byproduct['bom_cost'],
'level': level + 1,
})
lines.append({
'name': byproduct['name'],
'type': 'byproduct',
'quantity': byproduct['quantity'],
'uom': byproduct['uom'],
'prod_cost': byproduct['prod_cost'],
'bom_cost': byproduct['bom_cost'],
'level': level + 1,
'visible': byproducts_unfolded,
})
return lines
def _get_pdf_line(self, bom_id, product_id=False, qty=1, unfolded_ids=None, unfolded=False):
if unfolded_ids is None:
unfolded_ids = set()
@api.model
def _get_resupply_route_info(self, warehouse, product, quantity, bom=False):
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)
bom = self.env['mrp.bom'].browse(bom_id)
product_id = product_id or bom.product_id.id or bom.product_tmpl_id.product_variant_id.id
data = self._get_bom(bom_id=bom_id, product_id=product_id, line_qty=qty, )
pdf_lines = self._get_sub_lines(bom, product_id, qty, False, 1, unfolded_ids, unfolded)
data['components'] = []
data['lines'] = pdf_lines
data['extra_column_count'] = self._get_extra_column_count()
return data
@api.model
def _format_route_info(self, rules, rules_delay, warehouse, product, bom, quantity):
manufacture_rules = [rule for rule in rules if rule.action == 'manufacture' and bom]
if manufacture_rules:
# Need to get rules from Production location to get delays before production
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)
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,
'manufacture_delay': product.produce_delay + rules_delay,
}
return {}
def _get_extra_column_count(self):
return 0
@api.model
def _get_availabilities(self, product, quantity, product_info, bom_key, quantities_info, level, ignore_stock=False, components=False):
# Get availabilities according to stock (today & forecasted).
stock_state, stock_delay = ('unavailable', False)
if not ignore_stock:
stock_state, stock_delay = self._get_stock_availability(product, quantity, product_info, quantities_info)
# Get availabilities from applied resupply rules
components = components or []
route_info = product_info[product.id].get(bom_key)
resupply_state, resupply_delay = ('unavailable', False)
if product.detailed_type != 'product':
resupply_state, resupply_delay = ('available', 0)
elif route_info:
resupply_state, resupply_delay = self._get_resupply_availability(route_info, components)
base = {
'resupply_avail_delay': resupply_delay,
'stock_avail_state': stock_state,
}
if level != 0 and stock_state != 'unavailable':
return {**base, **{
'availability_display': self._format_date_display(stock_state, stock_delay),
'availability_state': stock_state,
'availability_delay': stock_delay,
}}
return {**base, **{
'availability_display': self._format_date_display(resupply_state, resupply_delay),
'availability_state': resupply_state,
'availability_delay': resupply_delay,
}}
@api.model
def _get_stock_availability(self, product, quantity, product_info, quantities_info):
date_today = self.env.context.get('from_date', fields.date.today())
if product.detailed_type != 'product':
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 float_compare(product_info[product.id]['consumptions'][stock_loc], quantities_info['free_qty'], precision_rounding=product.uom_id.rounding) <= 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')))
# Seek the closest date in the forecast report where consummed quantity >= forecasted quantity
closest_forecasted = self.env['report.stock.quantity']._read_group(domain, ['min_date:min(date)', 'product_id'], ['product_id'])
if closest_forecasted:
days_to_forecast = (closest_forecasted[0]['min_date'] - date_today).days
return ('expected', days_to_forecast)
return ('unavailable', False)
@api.model
def _get_resupply_availability(self, route_info, components):
if route_info.get('route_type') == 'manufacture':
max_component_delay = self._get_max_component_delay(components)
if max_component_delay is False:
return ('unavailable', False)
produce_delay = route_info.get('manufacture_delay', 0) + max_component_delay
return ('estimated', produce_delay)
return ('unavailable', False)
@api.model
def _get_max_component_delay(self, components):
max_component_delay = 0
for component in components:
line_delay = component.get('availability_delay', False)
if line_delay is False:
# This component isn't available right now and cannot be resupplied, so the manufactured product can't be resupplied either.
return False
max_component_delay = max(max_component_delay, line_delay)
return max_component_delay
@api.model
def _format_date_display(self, state, delay):
date_today = self.env.context.get('from_date', fields.date.today())
if state == 'available':
return _('Available')
if state == 'unavailable':
return _('Not Available')
if state == 'expected':
return _('Expected %s', format_date(self.env, date_today + timedelta(days=delay)))
if state == 'estimated':
return _('Estimated %s', format_date(self.env, date_today + timedelta(days=delay)))
return ''
@api.model
def _has_attachments(self, data):
return data['attachment_ids'] or any(self._has_attachments(component) for component in data.get('components', []))
+105 -239
View File
@@ -1,278 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="report_mrp_bom">
<div class="o_mrp_bom_report_page container py-3 py-lg-5 px-0 overflow-auto border-start border-end border-bottom bg-white">
<div t-if="data.get('components') or data.get('lines') or data.get('operations')">
<div class="o_mrp_bom_report_page container py-3 py-lg-5 px-0 bg-white">
<div t-if="data.get('lines')">
<div class="px-3 mb-5">
<h1>BoM Structure
<span t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_prod_cost">&amp; Cost</span>
</h1>
<h3>
<a href="#" t-if="data['report_type'] == 'html'" t-att-data-res-id="data['product'].id" t-att-data-model="data['product']._name" class="o_mrp_bom_action" t-out="data['bom_prod_name']"/>
<t t-else="" t-esc="data['bom_prod_name']"/>
</h3>
<hr t-if="data['bom'].code"/>
<h6 t-if="data['bom'].code">Reference: <t t-esc="data['bom'].code"/></h6>
<h1>BoM Overview</h1>
<h3 t-esc="data['name']"/>
<hr t-if="data['bom_code']"/>
<h6 t-if="data['bom_code']">Reference: <t t-esc="data['bom_code']"/></h6>
</div>
<t t-set="currency" t-value="data['currency']"/>
<table class="o_mrp_bom_expandable table">
<thead>
<tr>
<th>Product</th>
<th name="th_mrp_bom_h">BoM</th>
<th name="th_mrp_bom_h">Product</th>
<th class="text-end">Quantity</th>
<th class="text-start" groups="uom.group_uom">Unit of Measure</th>
<th t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_prod_cost text-end" title="This is the cost defined on the product.">Product Cost</th>
<th t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_bom_cost text-end" title="This is the cost based on the BoM of the product. It is computed by summing the costs of the components and operations needed to build the product.">BoM Cost</th>
<th t-if="data['report_type'] == 'html' and data['has_attachments']" class="o_mrp_has_attachments" title="Files attached to the product">Attachments</th>
<th class="text-end" groups="uom.group_uom"/>
<th t-if="data['show_availabilities']" class="text-end">Ready to Produce</th>
<th t-if="data['show_availabilities']" class="text-end">Free to Use / On Hand</th>
<th t-if="data['show_availabilities']" class="text-center">Availability</th>
<th t-if="data['show_lead_times']" class="text-end">Lead Time</th>
<th>Route</th>
<th t-if="data['show_costs']" class="text-end">BoM Cost</th>
<th t-if="data['show_costs']" class="text-end">Product Cost</th>
</tr>
</thead>
<tbody>
<tr>
<td name="td_mrp_bom" t-esc="data['name']"/>
<td class="text-end" t-esc="data['quantity']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
<td class="text-start" groups="uom.group_uom" t-esc="data['uom_name']"/>
<td t-if="data['show_availabilities']" class="text-end" t-esc="data['producible_qty']" t-options="{'widget': 'float', 'precision': 0}"/>
<td t-if="data['show_availabilities']" class="text-end">
<t t-esc="data['quantity_available']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/> /
<t t-esc="data['quantity_on_hand']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
</td>
<td t-if="data['show_availabilities']" class="text-center">
<t t-if="data.get('components_available', None) != None">
<span t-attf-class="{{'text-success' if data['components_available'] and data['availability_state'] != 'unavailable' else 'text-danger' }}" t-esc="data['availability_display']"/>
</t>
</td>
<td t-if="data['show_lead_times']" class="text-end">
<span t-if="data['lead_time'] is not False">
<t t-esc="data['lead_time']" t-options="{'widget': 'float', 'precision': 0}"/>
Days
</span>
</td>
<td>
<a href="#" t-if="data['report_type'] == 'html'" t-att-data-res-id="data['product'].id" t-att-data-model="'product.product'" class="o_mrp_bom_action"><t t-esc="data['bom_prod_name']"/></a><t t-else="" t-esc="data['bom_prod_name']"/>
</td>
<td name="td_mrp_bom">
<a href="#" t-if="data['report_type'] == 'html'" t-att-data-res-id="data['bom'].id" t-att-data-model="'mrp.bom'" class="o_mrp_bom_action" t-out="data['code']"/>
<t t-else="" t-out="data['code']"/>
</td>
<td class="text-end">
<span t-out="data['bom_qty']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
</td>
<td groups="uom.group_uom" t-put="data['bom'].product_uom_id.name"/>
<td t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_prod_cost text-end">
<span t-out="data['price']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_bom_cost text-end">
<span t-out="data['bom_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td t-if="data['report_type'] == 'html' and data['attachments']">
<a t-if="data['attachments']" href="#" role="button" t-att-data-res-id="data['attachments'].ids" t-att-data-model="'mrp.document'" class="o_mrp_show_attachment_action fa fa-fw o_button_icon fa-files-o"/>
<span t-if="data['route_name']"><t t-esc="data['route_name']"/>: </span>
<span t-esc="data['route_detail']"/>
</td>
<td t-if="data['show_costs']" class="text-end" t-esc="data['bom_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<td t-if="data['show_costs']" class="text-end" t-esc="data['prod_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</tr>
<t t-if="data['report_type'] == 'html'" t-call="mrp.report_mrp_bom_line"/>
<t t-if="data['report_type'] == 'pdf'" t-call="mrp.report_mrp_bom_pdf_line"/>
</tbody>
<tfoot>
<tr t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_prod_cost">
<td/>
<td t-foreach="range(data.get('extra_column_count', 0))" t-as="index"/>
<t t-call="mrp.report_mrp_bom_pdf_line"/>
<tr t-if="data['show_costs']">
<td name="td_mrp_bom_f" class="text-end">
<span t-if="data['byproducts']" t-out="data['bom_prod_name']"/>
</td>
<td class="text-end">
<strong>Unit Cost</strong>
</td>
<td groups="uom.group_uom">
<span t-out="data['bom'].product_uom_id.name"/>
</td>
<td class="text-end">
<span t-out="data['price']/data['bom_qty']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td class="text-end">
<span t-out="data['cost_share'] * data['total'] / data['bom_qty']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<span t-if="data['byproducts']" t-esc="data['name']"/>
</td>
<td class="text-end"><strong>Unit Cost</strong></td>
<td class="text-start" groups="uom.group_uom" t-esc="data['uom_name']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_lead_times']"/>
<td/>
<td class="text-end" t-esc="data['cost_share'] * data['bom_cost'] / data['quantity']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<td class="text-end" t-esc="data['prod_cost']/data['quantity']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</tr>
<t t-if="data['report_structure'] != 'bom_structure'" t-foreach="data['byproducts']" t-as="byproduct">
<tr class="o_mrp_bom_cost">
<t t-if="data['show_costs'] and data['byproducts']" t-foreach="data['byproducts']" t-as="byproduct">
<tr>
<td name="td_mrp_bom_byproducts_f" class="text-end" t-esc="byproduct['name']"/>
<td class="text-end"><strong>Unit Cost</strong></td>
<td class="text-start" groups="uom.group_uom" t-esc="byproduct['uom']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_availabilities']"/>
<td t-if="data['show_lead_times']"/>
<td/>
<td t-foreach="range(data.get('extra_column_count', 0))" t-as="index"/>
<td name="td_mrp_bom_byproducts_f" class="text-end">
<span t-out="byproduct['product_name']"/>
</td>
<td class="text-end">
<strong>Unit Cost</strong>
</td>
<td groups="uom.group_uom">
<span t-out="byproduct['product_uom']"/>
</td>
<td class="text-end">
<span t-out="byproduct['product_cost'] / byproduct['product_qty']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td class="text-end">
<span t-out="byproduct['cost_share'] * data['total'] / byproduct['product_qty']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td class="text-end" t-esc="byproduct['cost_share'] * data['bom_cost'] / byproduct['quantity']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<td class="text-end" t-esc="byproduct['prod_cost'] / byproduct['quantity']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</tr>
</t>
</tfoot>
</tbody>
</table>
</div>
<div t-else="" class="d-flex align-items-center justify-content-center h-50">
<h4 class="text-muted">No data available.</h4>
</div>
</div>
</template>
<template id="report_mrp_bom_line">
<t t-set="currency" t-value="data['currency']"/>
<t t-foreach="data['components']" t-as="l">
<t t-set="space_td" t-value="'margin-left: '+ str(l['level'] * 20) + 'px;'"/>
<tr class="o_mrp_bom_report_line" t-att-data-id="l['child_bom']" t-att-parent_id="l['parent_id']" t-att-data-line="l['line_id']" t-att-data-product_id="l['prod_id']" t-att-data-qty="l['prod_qty']" t-att-data-level="l['level']">
<td>
<div t-att-style="space_td">
<button t-if="l['child_bom']" t-att-data-function="'get_bom'" class="o_mrp_bom_unfoldable btn btn-light p-0" aria-label="Unfold" title="Unfold">
<i class="fa fa-fw fa-caret-right" role="img"/>
</button>
<div t-att-class="None if l['child_bom'] else 'o_mrp_bom_no_fold'" style="display:inline-block;">
<a href="#" t-att-data-res-id="l['prod_id']" t-att-data-model="'product.product'" class="o_mrp_bom_action" t-out="l['prod_name']"/>
</div>
<i t-if="l['phantom_bom']" class="fa fa-dropbox" title="This is a BoM of type Kit!" role="img" aria-label="This is a BoM of type Kit!"/>
</div>
</td>
<td name="td_mrp_bom">
<a href="#" t-att-data-res-id="l['child_bom']" t-att-data-model="'mrp.bom'" class="o_mrp_bom_action" t-out="l['code']"/>
</td>
<td class="text-end">
<span t-out="l['prod_qty']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
</td>
<td groups="uom.group_uom" t-out="l['prod_uom']"/>
<td class="o_mrp_prod_cost text-end">
<span t-out="l['prod_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td class="o_mrp_bom_cost text-end">
<span t-out="l['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td t-if="l['attachments']">
<a href="#" role="button" t-att-data-res-id="l['attachments'].ids" t-att-data-model="'mrp.document'" class="o_mrp_show_attachment_action">
<i class="fa fa-fw fa-files-o"/>
</a>
</td>
</tr>
</t>
<t t-if="data['operations']" name="operations">
<t t-set="space_td" t-value="'margin-left: '+ str(data['level'] * 20) + 'px;'"/>
<tr class="o_mrp_bom_report_line o_mrp_bom_cost" t-att-data-product_id="data['product'].id" t-att-data-id="'operation-' + str(data['bom'].id)" t-att-data-bom-id="data['bom'].id" t-att-parent_id="data['bom'].id" t-att-data-qty="data['bom_qty']" t-att-data-level="data['level']">
<td name="td_opr">
<span t-att-style="space_td"/>
<button class="o_mrp_bom_unfoldable btn btn-light ps-0 py-0" t-att-data-function="'get_operations'" aria-label="Unfold" title="Unfold">
<i class="fa fa-fw fa-caret-right" role="img"/>
Operations
</button>
</td>
<td t-foreach="range(data.get('extra_column_count', 0))" t-as="index"/>
<td/>
<td class="text-end">
<span t-out="data['operations_time']" t-options='{"widget": "float_time"}'/>
</td>
<td groups="uom.group_uom"><span>Minutes</span></td>
<td/>
<td class="o_mrp_bom_cost text-end">
<span t-out="data['operations_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td t-if="l['attachments']"/>
</tr>
</t>
<t t-if="data['byproducts']">
<t t-set="space_td" t-value="'margin-left: '+ str(data['level'] * 20) + 'px;'"/>
<tr class="o_mrp_bom_report_line o_mrp_bom_cost" t-att-data-id="'byproduct-' + str(data['bom'].id)" t-att-data-bom-id="data['bom'].id" t-att-parent_id="data['bom'].id" t-att-data-qty="data['bom_qty']" t-att-data-level="data['level']" t-att-data-total="data['total']">
<td name="td_byproducts">
<span t-att-style="space_td"/>
<button class="o_mrp_bom_unfoldable btn btn-light ps-0 py-0" t-att-data-function="'get_byproducts'" aria-label="Unfold" title="Unfold">
<i class="fa fa-fw fa-caret-right" role="img"/>
By-Products
</button>
</td>
<td t-foreach="range(data.get('extra_column_count', 0))" t-as="index"/>
<td/>
<td class="text-end" t-out="data['byproducts_total']"/>
<td groups="uom.group_uom"/>
<td/>
<td class="text-end">
<span t-out="data['byproducts_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
</tr>
</t>
</template>
<template id="report_mrp_operation_line">
<t t-set="currency" t-value="data['currency']"/>
<t t-foreach="data['operations']" t-as="op">
<t t-set="space_td" t-value="'margin-left: '+ str(op['level'] * 20) + 'px;'"/>
<tr class="o_mrp_bom_report_line o_mrp_bom_cost" t-att-parent_id="'operation-' + str(data['bom_id'])">
<td name="td_opr_line">
<span t-att-style="space_td"/>
<a href="#" t-att-data-res-id="op['operation'].id" t-att-data-model="'mrp.routing.workcenter'" class="o_mrp_bom_action" t-out="op['name']"/>
</td>
<td t-foreach="range(data.get('extra_column_count', 0))" t-as="index"/>
<td/>
<td class="text-end">
<span t-out="op['duration_expected']" t-options='{"widget": "float_time"}'/>
</td>
<td groups="uom.group_uom"><span>Minutes</span></td>
<td/>
<td class="o_mrp_bom_cost text-end">
<span t-out="op['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
</tr>
</t>
</template>
<template id="report_mrp_byproduct_line">
<t t-set="currency" t-value="data['currency']"/>
<t t-foreach="data['byproducts']" t-as="byproduct">
<t t-set="space_td" t-value="'margin-left: '+ str(byproduct['level'] * 20) + 'px;'"/>
<tr class="o_mrp_bom_report_line o_mrp_bom_cost" t-att-parent_id="'byproduct-' + str(data['bom_id'])">
<td name="td_byproduct_line">
<span t-att-style="space_td"/>
<a href="#" t-att-data-res-id="byproduct['product_id'].id" t-att-data-model="byproduct['product_id']._name" class="o_mrp_bom_action"><t t-esc="byproduct['product_name']"/></a>
</td>
<td t-foreach="range(data.get('extra_column_count', 0))" t-as="index"/>
<td/>
<td class="text-end">
<span t-out="byproduct['product_qty']"/>
</td>
<td groups="uom.group_uom"><span t-out="byproduct['product_uom']"/></td>
<td class="text-end">
<span t-out="byproduct['product_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td class="text-end">
<span t-out="byproduct['bom_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
</tr>
</t>
</template>
<template id="report_mrp_bom_pdf">
<t t-call="web.html_container">
<t t-call="mrp.report_mrp_bom"/>
</t>
</template>
<template id="report_mrp_bom_pdf_line">
<t t-set="currency" t-value="data['currency']"/>
<t t-foreach="data['lines']" t-as="l">
<t t-set="space_td" t-value="'margin-left: '+ str(l['level'] * 20) + 'px;'"/>
<tr t-if="data['report_structure'] != 'bom_structure' or l['type'] not in ['operation', 'byproduct']">
<td>
<div t-att-style="space_td">
<div><t t-esc="l['name']"/></div>
</div>
</td>
<td name="td_mrp_code">
<div t-if="l.get('code')" t-esc="l['code']" />
</td>
<td class="text-end">
<span>
<t t-if="l['type'] == 'operation'" t-esc="l['quantity']" t-options='{"widget": "float_time"}'/>
<t t-if="l['type'] in ['bom', 'byproduct']" t-esc="l['quantity']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
</span>
</td>
<td groups="uom.group_uom"><span><t t-esc="l['uom']"/></span></td>
<td t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_prod_cost text-end">
<span t-if="'prod_cost' in l" t-esc="l['prod_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td t-if="data['report_structure'] != 'bom_structure'" class="o_mrp_bom_cost text-end">
<span t-esc="l['bom_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
</tr>
</t>
<t t-set="currency" t-value="data['currency']"/>
<t t-foreach="data['lines']" t-as="l">
<tr t-if="l['visible'] and (l['type'] != 'operation' or data['show_operations'])">
<td name="td_mrp_code">
<span t-attf-style="margin-left: {{ str(l['level'] * 20) }}px"/>
<span t-esc="l['name']"/>
</td>
<td class="text-end">
<t t-if="l['type'] == 'operation'" t-esc="l['quantity']" t-options='{"widget": "float_time"}'/>
<t t-else="" t-esc="l['quantity']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
</td>
<td class="text-start" groups="uom.group_uom">
<t t-esc="l['uom']"/>
</td>
<td t-if="data['show_availabilities']" class="text-end">
<t t-if="l.get('producible_qty', False) is not False" t-esc="l['producible_qty']" t-options="{'widget': 'float', 'precision': 0}"/>
</td>
<td t-if="data['show_availabilities']" class="text-end">
<t t-if="l.get('quantity_available', False) is not False">
<t t-esc="l['quantity_available']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/> /
<t t-esc="l['quantity_on_hand']" t-options='{"widget": "float", "decimal_precision": "Product Unit of Measure"}'/>
</t>
</td>
<td t-if="data['show_availabilities']" class="text-center">
<t t-if="l.get('availability_state', None) != None">
<span t-attf-class="{{'text-success' if l['availability_state'] == 'available' else ''}}{{'text-warning' if l['availability_state'] == 'expected' else ''}}{{'text-danger' if l['availability_state'] == 'unavailable' else ''}}" t-esc="l['availability_display']"/>
</t>
</td>
<td t-if="data['show_lead_times']" class="text-end">
<span t-if="l.get('lead_time', False) is not False">
<t t-esc="l['lead_time']" t-options="{'widget': 'float', 'precision': 0}"/>
Days
</span>
</td>
<td>
<span t-if="l.get('route_name')"><t t-esc="l['route_name']"/>: <t t-esc="l['route_detail']"/></span>
</td>
<td t-if="data['show_costs']" t-attf-class="text-end {{ 'text-muted' if l['type'] == 'component' else '' }}" t-esc="l['bom_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<td t-if="data['show_costs']" class="text-end">
<span t-if="'prod_cost' in l" t-esc="l['prod_cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
</tr>
</t>
</template>
<template id="report_bom_structure">
<t t-set="data_report_landscape" t-value="True"/>
<t t-call="web.basic_layout">
<t t-call-assets="mrp.assets_common" t-js="False"/>
<t t-foreach="docs" t-as="data">
<div class="page">
<t t-call="mrp.report_mrp_bom"/>
+2 -2
View File
@@ -12,12 +12,12 @@
<field name="binding_type">report</field>
</record>
<record id="action_report_bom_structure" model="ir.actions.report">
<field name="name">BoM Structure</field>
<field name="name">BoM Overview</field>
<field name="model">mrp.bom</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">mrp.report_bom_structure</field>
<field name="report_file">mrp.report_bom_structure</field>
<field name="print_report_name">'Bom Structure - %s' % object.display_name</field>
<field name="print_report_name">'Bom Overview - %s' % object.display_name</field>
<field name="binding_model_id" ref="model_mrp_bom"/>
<field name="binding_type">report</field>
</record>
@@ -0,0 +1,178 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { BomOverviewControlPanel } from "../bom_overview_control_panel/mrp_bom_overview_control_panel";
import { BomOverviewTable } from "../bom_overview_table/mrp_bom_overview_table";
const { Component, EventBus, onMounted, onWillStart, onWillUnmount, useState } = owl;
export class BomOverviewComponent extends Component {
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.bus = new EventBus();
this.variants = [];
this.warehouses = [];
this.showVariants = false;
this.uomName = "";
this.extraColumnCount = 0;
this.unfoldedIds = new Set();
this.state = useState({
showOptions: {
uom: false,
availabilities: true,
costs: true,
operations: true,
leadTimes: true,
attachments: false,
},
currentWarehouse: null,
currentVariantId: null,
bomData: {},
bomQuantity: null,
});
onWillStart(async () => {
await this.getWarehouses();
await this.initBomData();
});
onMounted(() => {
this.bus.addEventListener("change-fold", (ev) => this._onChangeFolded(ev.detail));
this.bus.addEventListener("change-display", (ev) => this._onChangeDisplay(ev.detail));
this.bus.addEventListener("change-quantity", (ev) => this._onChangeBomQuantity(ev.detail));
this.bus.addEventListener("change-variant", (ev) => this._onChangeVariant(ev.detail));
this.bus.addEventListener("change-warehouse", (ev) => this._onChangeWarehouse(ev.detail));
this.bus.addEventListener("print", (ev) => this._onClickPrint(ev.detail));
});
onWillUnmount(() => {
this.bus.removeEventListener("change-fold", (ev) => this._onChangeFolded(ev.detail));
this.bus.removeEventListener("change-display", (ev) => this._onChangeDisplay(ev.detail));
this.bus.removeEventListener("change-quantity", (ev) => this._onChangeBomQuantity(ev.detail));
this.bus.removeEventListener("change-variant", (ev) => this._onChangeVariant(ev.detail));
this.bus.removeEventListener("change-warehouse", (ev) => this._onChangeWarehouse(ev.detail));
this.bus.removeEventListener("print", (ev) => this._onClickPrint(ev.detail));
});
}
//---- Data ----
async initBomData() {
const bomData = await this.getBomData();
this.state.bomQuantity = bomData["bom_qty"];
this.state.showOptions.uom = bomData["is_uom_applied"];
this.uomName = bomData["bom_uom_name"];
this.variants = bomData["variants"];
this.showVariants = bomData["is_variant_applied"];
if (this.showVariants) {
this.state.currentVariantId = Object.keys(this.variants)[0];
}
}
async getBomData() {
const args = [
this.activeId,
this.state.bomQuantity,
this.state.currentVariantId,
];
const context = this.state.currentWarehouse ? { warehouse: this.state.currentWarehouse.id } : {};
const bomData = await this.orm.call(
"report.mrp.report_bom_structure",
"get_html",
args,
{ context }
);
this.state.bomData = bomData["lines"];
this.state.showOptions.attachments = bomData["has_attachments"];
return bomData;
}
async getWarehouses() {
const warehouses = await this.orm.call(
"report.mrp.report_bom_structure",
"get_warehouses",
);
this.warehouses = warehouses;
this.state.currentWarehouse = warehouses[0];
}
//---- Handlers ----
_onChangeFolded(foldInfo) {
const { ids, isFolded } = foldInfo;
const operation = isFolded ? "delete" : "add";
ids.forEach(id => this.unfoldedIds[operation](id));
}
_onChangeDisplay(displayInfo) {
const { type, value } = displayInfo;
this.state.showOptions[type] = value;
}
async _onChangeBomQuantity(newQuantity) {
if (this.state.bomQuantity != newQuantity) {
this.state.bomQuantity = newQuantity;
await this.getBomData();
}
}
async _onChangeVariant(variantId) {
if (this.state.currentVariantId != variantId) {
this.state.currentVariantId = variantId;
await this.getBomData();
}
}
async _onChangeWarehouse(warehouseId) {
if (this.state.currentWarehouse.id != warehouseId) {
this.state.currentWarehouse = this.warehouses.find(wh => wh.id == warehouseId);
await this.getBomData();
}
}
async _onClickPrint(printAll) {
return this.actionService.doAction({
type: "ir.actions.report",
report_type: "qweb-pdf",
report_name: this.getReportName(printAll),
report_file: "mrp.report_bom_structure",
});
}
//---- Getters ----
get activeId() {
return this.props.action.context.active_id;
}
// ---- Helpers ----
getReportName(printAll) {
let reportName = "mrp.report_bom_structure?docids=" + this.activeId +
"&availabilities=" + this.state.showOptions.availabilities +
"&costs=" + this.state.showOptions.costs +
"&operations=" + this.state.showOptions.operations +
"&lead_times=" + this.state.showOptions.leadTimes +
"&quantity=" + (this.state.bomQuantity || 1) +
"&unfolded_ids=" + JSON.stringify(Array.from(this.unfoldedIds)) +
"&warehouse_id=" + (this.state.currentWarehouse ? this.state.currentWarehouse.id : false);
if (printAll) {
reportName += "&all_variants=1";
} else if (this.showVariants && this.state.currentVariantId) {
reportName += "&variant=" + this.state.currentVariantId;
}
return reportName;
}
}
BomOverviewComponent.template = "mrp.BomOverviewComponent";
BomOverviewComponent.components = {
BomOverviewControlPanel,
BomOverviewTable,
};
registry.category("actions").add("mrp_bom_report", BomOverviewComponent);
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="mrp.BomOverviewComponent" class="o_action" owl="1">
<BomOverviewControlPanel
bus="bus"
bomQuantity="state.bomQuantity"
uomName="uomName"
variants="variants"
showOptions="state.showOptions"
showVariants="showVariants"
currentWarehouse="state.currentWarehouse"
warehouses="warehouses"/>
<BomOverviewTable
bus="bus"
uomName="uomName"
showOptions="state.showOptions"
currentWarehouseId="state.currentWarehouse.id"
data="state.bomData"/>
</div>
</templates>
@@ -0,0 +1,115 @@
/** @odoo-module **/
import { BomOverviewLine } from "../bom_overview_line/mrp_bom_overview_line";
import { BomOverviewExtraBlock } from "../bom_overview_extra_block/mrp_bom_overview_extra_block";
const { Component, onMounted, onWillUnmount, onWillUpdateProps, useState } = owl;
export class BomOverviewComponentsBlock extends Component {
setup() {
const childFoldstate = this.childIds.reduce((prev, curr) => ({ ...prev, [curr]: !this.props.unfoldAll}), {});
this.state = useState({
...childFoldstate,
unfoldAll: this.props.unfoldAll || false,
});
if (this.props.unfoldAll) {
this.props.bus.trigger("change-fold", { ids: this.childIds, isFolded: false });
}
onMounted(() => {
if (this.hasComponents) {
this.props.bus.addEventListener(`toggle-fold-${this.identifier}`, (ev) => this._onToggleFolded(ev.detail));
this.props.bus.addEventListener("unfold-all", () => this._unfoldAll());
}
});
onWillUpdateProps(newProps => {
if (this.data.product_id != newProps.data.product_id) {
this.childIds.forEach(id => delete this.state[id]);
const newChildIds = this.getHasComponents(newProps.data) ? newProps.data.components.map(c => this.getIdentifier(c)) : [];
newChildIds.forEach(id => this.state[id] = true);
this.state.unfoldAll = false;
}
});
onWillUnmount(() => {
if (this.hasComponents) {
this.props.bus.trigger("change-fold", { ids: this.childIds, isFolded: true });
this.props.bus.removeEventListener(`toggle-fold-${this.identifier}`, (ev) => this._onToggleFolded(ev.detail));
this.props.bus.removeEventListener("unfold-all", () => this._unfoldAll());
}
});
}
//---- Handlers ----
_onToggleFolded(foldId) {
const newState = !this.state[foldId];
this.state[foldId] = newState;
this.state.unfoldAll = false;
this.props.bus.trigger("change-fold", { ids: [foldId], isFolded: newState });
}
_unfoldAll() {
const allChildIds = this.childIds;
this.state.unfoldAll = true;
allChildIds.forEach(id => this.state[id] = false);
this.props.bus.trigger("change-fold", { ids: allChildIds, isFolded: false });
}
//---- Getters ----
get data() {
return this.props.data;
}
get hasComponents() {
return this.getHasComponents(this.data);
}
get childIds() {
return this.hasComponents ? this.data.components.map(c => this.getIdentifier(c)) : [];
}
get identifier() {
return this.getIdentifier(this.data);
}
get operationsId() {
return this.getIdentifier(this.data, "operations");
}
get byproductsId() {
return this.getIdentifier(this.data, "byproducts");
}
get showOperations() {
return this.props.showOptions.operations;
}
//---- Utils ----
getHasComponents(data) {
return data.components && data.components.length > 0;
}
getIdentifier(data, type=null) {
return `${type ? type : data.type}_${data.index}`;
}
}
BomOverviewComponentsBlock.template = "mrp.BomOverviewComponentsBlock";
BomOverviewComponentsBlock.components = {
BomOverviewLine,
BomOverviewComponentsBlock,
BomOverviewExtraBlock,
};
BomOverviewComponentsBlock.props = {
bus: Object,
unfoldAll: { type: Boolean, optional: true },
showOptions: Object,
currentWarehouseId: { type: Number, optional: true },
data: Object,
};
BomOverviewComponentsBlock.defaultProps = {
unfoldAll: false,
};
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewComponentsBlock" owl="1">
<t name="components" t-if="hasComponents">
<t t-foreach="data.components" t-as="line" t-key="line.index">
<BomOverviewLine
bus="props.bus"
isFolded="state[getIdentifier(line)]"
parentId="identifier"
showOptions="props.showOptions"
data="line"/>
<t t-if="!state[getIdentifier(line)] &amp;&amp; hasComponents">
<BomOverviewComponentsBlock
bus="props.bus"
unfoldAll="state.unfoldAll"
showOptions="props.showOptions"
currentWarehouseId="props.currentWarehouseId"
data="line"/>
</t>
</t>
</t>
<t name="operations" t-if="showOperations &amp;&amp; !!data.operations &amp;&amp; data.operations.length > 0">
<BomOverviewExtraBlock
bus="props.bus"
unfoldAll="state.unfoldAll"
type="'operations'"
showOptions="props.showOptions"
data="data"/>
</t>
<t name="byproducts" t-if="!!data.byproducts &amp;&amp; data.byproducts.length > 0">
<BomOverviewExtraBlock
bus="props.bus"
unfoldAll="state.unfoldAll"
type="'byproducts'"
showOptions="props.showOptions"
data="data"/>
</t>
</t>
</templates>
@@ -0,0 +1,69 @@
/** @odoo-module **/
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { BomOverviewDisplayFilter } from "../bom_overview_display_filter/mrp_bom_overview_display_filter";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const { Component } = owl;
export class BomOverviewControlPanel extends Component {
setup() {
this.controlPanelDisplay = {};
// Cannot use 'control-panel-bottom-right' slot without this, as viewSwitcherEntries doesn't exist in this.env.config here.
this.env.config.viewSwitcherEntries = [];
}
//---- Handlers ----
updateQuantity(ev) {
const newVal = isNaN(ev.target.value) ? 1 : parseInt(ev.target.value);
this.props.bus.trigger("change-quantity", newVal);
}
onKeyPress(ev) {
if (ev.keyCode === 13 || ev.which === 13) {
ev.preventDefault();
this.updateQuantity(ev);
}
}
changeVariant(ev) {
this.props.bus.trigger("change-variant", ev.target.value);
}
changeWarehouse(warehouseId) {
this.props.bus.trigger("change-warehouse", warehouseId);
}
clickPrint(printAll=false) {
this.props.bus.trigger("print", printAll);
}
clickUnfold() {
this.props.bus.trigger("unfold-all");
}
}
BomOverviewControlPanel.template = "mrp.BomOverviewControlPanel";
BomOverviewControlPanel.components = {
Dropdown,
DropdownItem,
ControlPanel,
BomOverviewDisplayFilter,
};
BomOverviewControlPanel.props = {
bus: Object,
bomQuantity: Number,
showOptions: Object,
showVariants: { type: Boolean, optional: true },
variants: { type: Object, optional: true },
showUom: { type: Boolean, optional: true },
uomName: { type: String, optional: true },
currentWarehouse: Object,
warehouses: { type: Array, optional: true },
};
BomOverviewControlPanel.defaultProps = {
variants: {},
warehouses: [],
};
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewControlPanel" owl="1">
<ControlPanel display="controlPanelDisplay">
<t t-set-slot="control-panel-bottom-left-buttons">
<div class="o_cp_buttons">
<div class="o_list_buttons o_mrp_bom_report_buttons">
<button t-on-click="() => this.clickPrint()" type="button" class="btn btn-primary">Print</button>
<t t-if="props.showVariants">
<button t-on-click="() => this.clickPrint(true)" type="button" class="btn btn-primary ms-1">Print All Variants</button>
</t>
<button t-on-click="clickUnfold" type="button" class="btn btn-primary ms-1">Unfold</button>
</div>
</div>
</t>
<t t-set-slot="control-panel-top-right">
<form class="row gx-0">
<div class="col-lg-8 row">
<div class="col-lg-11 row gx-1 mb-2">
<label for="bom_quantity" class="col-xl-2 col-lg-3 col-sm-4">Quantity:</label>
<div t-attf-class="{{ props.showOptions.uom ? 'col-xl-7 col-lg-6 col-sm-5' : 'col-xl-10 col-lg-9 col-sm-8' }}">
<input id="bom_quantity" type="number" step="any" t-on-change="ev => this.updateQuantity(ev)" t-on-keypress="ev => this.onKeyPress(ev)" t-att-value="props.bomQuantity" min="1" class="o_input"/>
</div>
<div t-if="props.showOptions.uom" class="col-xl-3 col-lg-3 col-sm-3">
<span t-esc="props.uomName"/>
</div>
</div>
<div t-if="props.showVariants" class="col-lg-11 row gx-1 mb-1">
<label class="col-xl-2 col-lg-3 col-sm-4">Variant:</label>
<div class="col-xl-10 col-lg-9 col-sm-8">
<select class="o_input" t-on-change="changeVariant">
<option t-foreach="props.variants" t-as="variant" t-att-value="variant" t-key="variant">
<t t-esc="props.variants[variant]"/>
</option>
</select>
</div>
</div>
</div>
</form>
</t>
<t t-set-slot="control-panel-bottom-right">
<div class="o_cp_bottom_right_sp">
<t t-if="props.warehouses.length > 1">
<Dropdown class="'btn-group'" togglerClass="'btn btn-secondary'">
<t t-set-slot="toggler">
<span class="fa fa-home"/> Warehouse: <span t-esc="props.currentWarehouse.name"/>
</t>
<t t-foreach="props.warehouses" t-as="wh" t-key="wh.id">
<DropdownItem onSelected="() => this.changeWarehouse(wh.id)" t-esc="wh.name"/>
</t>
</Dropdown>
</t>
<BomOverviewDisplayFilter
bus="props.bus"
showOptions="props.showOptions"/>
</div>
</t>
</ControlPanel>
</t>
</templates>
@@ -0,0 +1,51 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
const { Component } = owl;
export class BomOverviewDisplayFilter extends Component {
setup() {
this.displayOptions = {
availabilities: this.env._t('Availabilities'),
leadTimes: this.env._t('Lead Times'),
costs: this.env._t('Costs'),
operations: this.env._t('Operations'),
};
}
//---- Handlers ----
onClickDisplay(optionKey) {
this.props.bus.trigger("change-display", { type: optionKey, value: !this.props.showOptions[optionKey] });
}
//---- Getters ----
get displayableOptions() {
return Object.keys(this.displayOptions);
}
get currentDisplayedNames() {
return this.displayableOptions.filter(key => this.props.showOptions[key]).map(key => this.displayOptions[key]).join(", ");
}
}
BomOverviewDisplayFilter.template = "mrp.BomOverviewDisplayFilter";
BomOverviewDisplayFilter.components = {
Dropdown,
DropdownItem,
}
BomOverviewDisplayFilter.props = {
bus: Object,
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
};
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewDisplayFilter" owl="1">
<Dropdown class="'btn-group'" togglerClass="'btn btn-secondary'">
<t t-set-slot="toggler">
<span class="fa fa-filter"/>
Display: <span t-esc="currentDisplayedNames"/>
</t>
<t t-foreach="displayableOptions" t-as="optionKey" t-key="optionKey">
<DropdownItem parentClosingMode="'none'" class="{ o_menu_item: true, selected: this.props.showOptions[optionKey] }" onSelected="() => this.onClickDisplay(optionKey)" t-esc="displayOptions[optionKey]"/>
</t>
</Dropdown>
</t>
</templates>
@@ -0,0 +1,74 @@
/** @odoo-module **/
import { BomOverviewLine } from "../bom_overview_line/mrp_bom_overview_line";
import { BomOverviewSpecialLine } from "../bom_overview_special_line/mrp_bom_overview_special_line";
const { Component, onMounted, onWillUnmount, onWillUpdateProps, useState } = owl;
export class BomOverviewExtraBlock extends Component {
setup() {
this.state = useState({
isFolded: !this.props.unfoldAll,
});
if (this.props.unfoldAll) {
this.props.bus.trigger("change-fold", { ids: [this.identifier], isFolded: false });
}
onMounted(() => {
this.props.bus.addEventListener(`toggle-fold-${this.identifier}`, () => this._onToggleFolded());
this.props.bus.addEventListener("unfold-all", () => this._unfold());
});
onWillUpdateProps(newProps => {
if (this.props.data.product_id != newProps.data.product_id) {
this.state.isFolded = true;
}
});
onWillUnmount(() => {
this.props.bus.trigger("change-fold", { ids: [this.identifier], isFolded: true });
this.props.bus.removeEventListener(`toggle-fold-${this.identifier}`, () => this._onToggleFolded());
this.props.bus.removeEventListener("unfold-all", () => this._unfold());
});
}
//---- Handlers ----
_onToggleFolded() {
const newState = !this.state.isFolded;
this.state.isFolded = newState;
this.props.bus.trigger("change-fold", { ids: [this.identifier], isFolded: newState });
}
_unfold() {
this.state.isFolded = false;
this.props.bus.trigger("change-fold", { ids: [this.identifier], isFolded: false });
}
//---- Getters ----
get identifier() {
return `${this.props.type}_${this.props.data.index}`;
}
}
BomOverviewExtraBlock.template = "mrp.BomOverviewExtraBlock";
BomOverviewExtraBlock.components = {
BomOverviewLine,
BomOverviewSpecialLine,
};
BomOverviewExtraBlock.props = {
bus: Object,
unfoldAll: { type: Boolean, optional: true },
type: {
type: String,
validate: t => ["operations", "byproducts"].includes(t),
},
showOptions: Object,
data: Object,
};
BomOverviewExtraBlock.defaultProps = {
showAvailabilities: false,
showCosts: false,
extraColumnCount: 0,
};
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mrp.BomOverviewExtraBlock" owl="1">
<BomOverviewSpecialLine
bus="props.bus"
type="props.type"
isFolded="state.isFolded"
parentId="identifier"
showOptions="props.showOptions"
data="props.data"/>
<t t-if="!state.isFolded" t-foreach="props.type == 'operations' ? props.data.operations : props.data.byproducts" t-as="extra_data" t-key="extra_data.index">
<BomOverviewLine
bus="props.bus"
showOptions="props.showOptions"
data="extra_data"/>
</t>
</t>
</templates>
@@ -0,0 +1,174 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
const { Component } = owl;
export class BomOverviewLine extends Component {
setup() {
this.actionService = useService("action");
this.ormService = useService("orm");
this.formatFloat = formatFloat;
this.formatFloatTime = formatFloatTime;
this.formatMonetary = (val) => formatMonetary(val, { currencyId: this.data.currency_id });
}
//---- Handlers ----
async goToRoute(routeType) {
if (routeType == "manufacture") {
return this.goToAction(this.data.bom_id, "mrp.bom");
}
}
async goToAction(id, model) {
return this.actionService.doAction({
type: "ir.actions.act_window",
res_model: model,
res_id: id,
views: [[false, "form"]],
target: "current",
context: {
active_id: id,
},
});
}
async goToForecast() {
const action = await this.ormService.call(
this.data.link_model,
this.forecastAction,
[[this.data.link_id]],
);
action.context = {
active_model: this.data.link_model,
active_id: this.data.link_id,
};
if (this.props.currentWarehouseId) {
action.context["warehouse"] = this.props.currentWarehouseId;
}
return this.actionService.doAction(action);
}
async goToAttachment() {
return this.actionService.doAction({
name: this.env._t("Attachments"),
type: "ir.actions.act_window",
res_model: "mrp.document",
domain: [["id", "in", this.data.attachment_ids]],
views: [[false, "kanban"], [false, "list"], [false, "form"]],
view_mode: "kanban,list,form",
target: "current",
});
}
onToggleFolded() {
if (this.props.parentId) {
this.props.bus.trigger(`toggle-fold-${this.props.parentId}`, this.identifier);
}
}
//---- Getters ----
get data() {
return this.props.data;
}
get identifier() {
return `${this.data.type}_${this.data.index}`;
}
get hasComponents() {
return this.data.components && this.data.components.length > 0;
}
get hasQuantity() {
return this.data.hasOwnProperty('quantity_available') && this.data.quantity_available !== false;
}
get hasLeadTime() {
return this.data.hasOwnProperty('lead_time') && this.data.lead_time !== false;
}
get hasFoldButton() {
return this.data.level > 0 && this.hasComponents;
}
get marginMultiplicator() {
return this.data.level - (this.hasFoldButton ? 1 : 0);
}
get showAvailabilities() {
return this.props.showOptions.availabilities;
}
get showCosts() {
return this.props.showOptions.costs;
}
get showLeadTimes() {
return this.props.showOptions.leadTimes;
}
get showUom() {
return this.props.showOptions.uom;
}
get showAttachments() {
return this.props.showOptions.attachments;
}
get availabilityColorClass() {
// For first line, another rule applies : green if doable now, red otherwise.
if (this.data.hasOwnProperty('components_available')) {
if (this.data.components_available && this.data.availability_state != 'unavailable') {
return "text-success";
} else {
return "text-danger";
}
}
switch (this.data.availability_state) {
case "available":
return "text-success";
case "expected":
return "text-warning";
case "unavailable":
return "text-danger";
default:
return "";
}
}
get forecastAction() {
switch (this.data.link_model) {
case "product.product":
return "action_product_forecast_report";
case "product.template":
return "action_product_tmpl_forecast_report";
}
}
}
BomOverviewLine.template = "mrp.BomOverviewLine";
BomOverviewLine.props = {
bus: Object,
isFolded: { type: Boolean, optional: true },
parentId: { type: String, optional: true },
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
currentWarehouseId: { type: Number, optional: true },
data: Object,
};
BomOverviewLine.defaultProps = {
isFolded: true,
};
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<tr t-name="mrp.BomOverviewLine" owl="1">
<td name="td_mrp_bom">
<div t-attf-style="margin-left: {{ marginMultiplicator * 20 }}px">
<t t-if="data.level > 0 &amp;&amp; hasComponents">
<button t-on-click="onToggleFolded" class="o_mrp_bom_unfoldable btn btn-light p-0" t-attf-aria-label="{{ props.isFolded ? 'Unfold' : 'Fold' }}" t-attf-title="{{ props.isFolded ? 'Unfold' : 'Fold' }}" style="margin-right: 1px">
<i t-attf-class="fa fa-fw fa-caret-{{ props.isFolded ? 'right' : 'down' }}" role="img"/>
</button>
</t>
<div t-attf-class="d-inline-block">
<a href="#" t-on-click.prevent="() => this.goToAction(data.link_id, data.link_model)" t-esc="data.name"/>
</div>
<t t-if="data.phantom_bom">
<div class="fa fa-dropbox" title="This is a BoM of type Kit!" role="img" aria-label="This is a BoM of type Kit!"/>
</t>
</div>
</td>
<td class="text-end">
<t t-if="data.type == 'operation'" t-esc="formatFloatTime(data.quantity)"/>
<t t-else="" t-esc="formatFloat(data.quantity)"/>
</td>
<td t-if="showUom" class="text-start" t-esc="data.uom_name"/>
<td t-if="showAvailabilities" class="text-end">
<t t-if="data.hasOwnProperty('producible_qty')" t-esc="data.producible_qty"/>
</td>
<td t-if="showAvailabilities" class="text-end">
<t t-if="hasQuantity">
<t t-esc="formatFloat(data.quantity_available)"/> /
<t t-esc="formatFloat(data.quantity_on_hand)"/>
</t>
</td>
<td t-if="showAvailabilities" class="text-center">
<t t-if="data.hasOwnProperty('availability_state')">
<span t-attf-class="{{availabilityColorClass}}" t-esc="data.availability_display"/>
<a href="#" role="button" t-on-click.prevent="goToForecast" title="Forecast Report" t-attf-class="fa fa-fw fa-area-chart o_mrp_bom_forecast ms-1 {{availabilityColorClass}}"/>
</t>
</td>
<td t-if="showLeadTimes" class="text-end">
<span t-if="hasLeadTime"><t t-esc="data.lead_time"/> Days</span>
</td>
<td>
<div t-if="data.route_name">
<span><t t-esc="data.route_name"/>: </span>
<a href="#" t-on-click.prevent="() => this.goToRoute(data.route_type)" t-esc="data.route_detail"/>
</div>
</td>
<td t-if="showCosts" t-attf-class="text-end {{ data.type == 'component' ? 'opacity-50' : '' }}" t-esc="formatMonetary(data.bom_cost)"/>
<td t-if="showCosts" class="text-end">
<span t-if="data.hasOwnProperty('prod_cost')" t-esc="formatMonetary(data.prod_cost)"/>
</td>
<td t-if="showAttachments" class="text-center">
<span t-if="!!data.attachment_ids &amp;&amp; data.attachment_ids.length > 0">
<a href="#" role="button" t-on-click.prevent="goToAttachment" class="fa fa-fw o_button_icon fa-files-o"/>
</span>
</td>
</tr>
</templates>
@@ -0,0 +1,76 @@
/** @odoo-module **/
import { formatFloat, formatFloatTime, formatMonetary } from "@web/views/fields/formatters";
const { Component } = owl;
export class BomOverviewSpecialLine extends Component {
setup() {
this.formatFloat = formatFloat;
this.formatFloatTime = formatFloatTime;
this.formatMonetary = (val) => formatMonetary(val, { currencyId: this.data.currency_id });
}
//---- Handlers ----
onToggleFolded() {
if (this.props.parentId) {
this.props.bus.trigger(`toggle-fold-${this.props.parentId}`);
}
}
//---- Getters ----
get data() {
return this.props.data;
}
get hasFoldButton() {
return ["operations", "byproducts"].includes(this.props.type);
}
get showAvailabilities() {
return this.props.showOptions.availabilities;
}
get showCosts() {
return this.props.showOptions.costs;
}
get showLeadTimes() {
return this.props.showOptions.leadTimes;
}
get showUom() {
return this.props.showOptions.uom;
}
get showAttachments() {
return this.props.showOptions.attachments;
}
}
BomOverviewSpecialLine.template = "mrp.BomOverviewSpecialLine";
BomOverviewSpecialLine.props = {
bus: Object,
type: String,
parentId: { type: String, optional: true },
isFolded: { type: Boolean, optional: true },
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
data: Object,
toggleFolded: { type: Function, optional: true },
};
BomOverviewSpecialLine.defaultProps = {
isFolded: true,
toggleFolded: () => {},
};
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<tr t-name="mrp.BomOverviewSpecialLine" owl="1">
<td name="td_mrp_bom">
<span t-attf-style="margin-left: {{ data.level * 20 }}px"/>
<button t-if="hasFoldButton" t-on-click="onToggleFolded" t-attf-class="o_mrp_bom_{{ props.isFolded ? 'unfoldable' : 'foldable' }} btn btn-light ps-0 py-0" t-attf-aria-label="{{ props.isFolded ? 'Unfold' : 'Fold' }}" t-attf-title="{{ props.isFolded ? 'Unfold' : 'Fold' }}">
<i t-attf-class="fa fa-fw fa-caret-{{ props.isFolded ? 'right' : 'down' }}" role="img"/>
<t t-if="props.type == 'operations'">Operations</t>
<t t-elif="props.type == 'byproducts'">By-Products</t>
</button>
</td>
<td name="quantity" class="text-end">
<span t-if="props.type == 'operations'" t-esc="formatFloatTime(data.operations_time)"/>
<span t-elif="props.type == 'byproducts'" t-esc="formatFloat(data.byproducts_total)"/>
</td>
<td name="uom" t-if="showUom" class="text-start">
<span t-if="props.type == 'operations'">Minutes</span>
</td>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showLeadTimes"/>
<td/>
<td name="bom_cost" t-if="showCosts" class="text-end">
<span t-if="props.type == 'operations'" t-esc="formatMonetary(data.operations_cost)"/>
<span t-elif="props.type == 'byproducts'" t-esc="formatMonetary(data.byproducts_cost)"/>
</td>
<td name="prod_cost" t-if="showCosts" class="text-end"/>
<td t-if="showAttachments"/>
</tr>
</templates>
@@ -0,0 +1,84 @@
/** @odoo-module **/
import { formatMonetary, formatFloat } from "@web/views/fields/formatters";
import { useService } from "@web/core/utils/hooks";
import { BomOverviewLine } from "../bom_overview_line/mrp_bom_overview_line";
import { BomOverviewComponentsBlock } from "../bom_overview_components_block/mrp_bom_overview_components_block";
const { Component } = owl;
export class BomOverviewTable extends Component {
setup() {
this.actionService = useService("action");
this.formatFloat = formatFloat;
this.formatMonetary = (val) => formatMonetary(val, { currencyId: this.data.currency_id });
}
//---- Handlers ----
async goToProduct() {
return this.actionService.doAction({
type: "ir.actions.act_window",
res_model: this.data.link_model,
res_id: this.data.link_id,
views: [[false, "form"]],
target: "current",
context: {
active_id: this.data.link_id,
},
});
}
//---- Getters ----
get data() {
return this.props.data;
}
get showAvailabilities() {
return this.props.showOptions.availabilities;
}
get showCosts() {
return this.props.showOptions.costs;
}
get showOperations() {
return this.props.showOptions.operations;
}
get showLeadTimes() {
return this.props.showOptions.leadTimes;
}
get showUom() {
return this.props.showOptions.uom;
}
get showAttachments() {
return this.props.showOptions.attachments;
}
}
BomOverviewTable.template = "mrp.BomOverviewTable";
BomOverviewTable.components = {
BomOverviewLine,
BomOverviewComponentsBlock,
};
BomOverviewTable.props = {
bus: Object,
showOptions: {
type: Object,
shape: {
availabilities: Boolean,
costs: Boolean,
operations: Boolean,
leadTimes: Boolean,
uom: Boolean,
attachments: Boolean,
},
},
uomName: { type: String, optional: true },
currentWarehouseId: { type: Number, optional: true },
data: Object,
};
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<div t-name="mrp.BomOverviewTable" class="o_content" owl="1">
<div class="o_mrp_bom_report_page py-3 py-lg-5 px-0 overflow-auto border-bottom bg-white">
<div t-if="!!data.components || !!data.lines || !!data.operations" class="container-fluid">
<div class="d-flex mb-5">
<div class="me-auto">
<h2><a href="#" t-on-click.prevent="goToProduct" t-esc="data.name"/></h2>
<hr t-if="data.bom_code"/>
<h6 t-if="data.bom_code">Reference: <t t-esc="data.bom_code"/></h6>
</div>
<div class="text-center">
<h3><t t-esc="formatFloat(data.quantity_available)"/> <t t-if="showUom" t-esc="props.uomName"/></h3>
<span>Free to Use</span>
</div>
<div t-if="data.hasOwnProperty('earliest_capacity')" class="ps-5 text-center">
<h3><t t-esc="formatFloat(data.earliest_capacity)"/> <t t-if="showUom" t-esc="props.uomName"/></h3>
<span t-esc="data.earliest_date"/>
</div>
<div t-if="data.hasOwnProperty('leftover_capacity')" class="ps-5 text-center">
<h3><t t-esc="formatFloat(data.leftover_capacity)"/> <t t-if="showUom" t-esc="props.uomName"/></h3>
<span t-esc="data.leftover_date"/>
</div>
</div>
<table class="o_mrp_bom_expandable table">
<thead>
<tr>
<th name="th_mrp_bom_h">Product</th>
<th t-attf-class="{{ showUom ? 'text-center' : 'text-end' }}" t-attf-colspan="{{ showUom ? 2 : 1 }}">Quantity</th>
<th t-if="showAvailabilities" class="text-end">Ready to Produce</th>
<th t-if="showAvailabilities" class="text-end" title="Availabilities on products.">Free to Use / On Hand</th>
<th t-if="showAvailabilities" class="text-center" title="Reception time estimation.">Availability</th>
<th t-if="showLeadTimes" class="text-end" title="Resupply lead time.">Lead Time</th>
<th>Route</th>
<th t-if="showCosts" class="text-end" title="This is the cost based on the BoM of the product. It is computed by summing the costs of the components and operations needed to build the product.">BoM Cost</th>
<th t-if="showCosts" class="text-end" title="This is the cost defined on the product.">Product Cost</th>
<th t-if="showAttachments" class="text-center" title="Files attached to the product.">Attachments</th>
</tr>
</thead>
<tbody>
<BomOverviewLine
bus="props.bus"
showOptions="props.showOptions"
currentWarehouseId="props.currentWarehouseId"
data="data"/>
<BomOverviewComponentsBlock
bus="props.bus"
showOptions="props.showOptions"
currentWarehouseId="props.currentWarehouseId"
data="data"/>
</tbody>
<tfoot t-if="showCosts">
<tr>
<td name="td_mrp_bom_f" class="text-end">
<span t-if="!!data.byproducts &amp;&amp; data.byproducts.length > 0" t-esc="data.name"/>
</td>
<td class="text-end"><strong>Unit Cost</strong></td>
<td t-if="showUom"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showLeadTimes"/>
<td/>
<td class="text-end" t-esc="formatMonetary(data.cost_share * data.bom_cost / data.quantity)"/>
<td class="text-end" t-esc="formatMonetary(data.prod_cost / data.quantity)"/>
<td t-if="showAttachments"/>
</tr>
<t t-if="data.byproducts &amp;&amp; data.byproducts.length > 0" t-foreach="data.byproducts" t-as="byproduct" t-key="byproduct.id">
<tr>
<td name="td_mrp_bom_b" class="text-end" t-esc="byproduct.name"/>
<td class="text-end"><strong>Unit Cost</strong></td>
<td t-if="showUom"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showAvailabilities"/>
<td t-if="showLeadTimes"/>
<td/>
<td class="text-end" t-esc="formatMonetary(byproduct.cost_share * data.bom_cost / byproduct.quantity)"/>
<td class="text-end" t-esc="formatMonetary(byproduct.prod_cost / byproduct.quantity)"/>
<td t-if="showAttachments"/>
</tr>
</t>
</tfoot>
</table>
</div>
<div t-else="" class="d-flex align-items-center justify-content-center h-50">
<h4 class="text-muted">No data available.</h4>
</div>
</div>
</div>
</templates>
-282
View File
@@ -1,282 +0,0 @@
/** @odoo-module **/
import core from 'web.core';
import stock_report_generic from 'stock.stock_report_generic';
var QWeb = core.qweb;
var _t = core._t;
var MrpBomReport = stock_report_generic.extend({
events: {
'click .o_mrp_bom_unfoldable': '_onClickUnfold',
'click .o_mrp_bom_foldable': '_onClickFold',
'click .o_mrp_bom_action': '_onClickAction',
'click .o_mrp_show_attachment_action': '_onClickShowAttachment',
},
get_html: function() {
var self = this;
var args = [
this.given_context.active_id,
this.given_context.searchQty || false,
this.given_context.searchVariant,
];
return this._rpc({
model: 'report.mrp.report_bom_structure',
method: 'get_html',
args: args,
context: this.given_context,
})
.then(function (result) {
self.data = result;
if (! self.given_context.searchVariant) {
self.given_context.searchVariant = result.is_variant_applied && Object.keys(result.variants)[0];
}
});
},
set_html: function() {
var self = this;
return this._super().then(function () {
self.$('.o_content').html(self.data.lines);
self.renderSearch();
self.update_cp();
});
},
render_html: function(event, $el, result){
if (result.indexOf('mrp.document') > 0) {
if (this.$('.o_mrp_has_attachments').length === 0) {
var column = $('<th/>', {
class: 'o_mrp_has_attachments',
title: 'Files attached to the product Attachments',
text: 'Attachments',
});
this.$('table thead th:last-child').after(column);
}
}
$el.after(result);
$(event.currentTarget).toggleClass('o_mrp_bom_foldable o_mrp_bom_unfoldable').find('i').toggleClass('fa-caret-right fa-caret-down');
this._reload_report_type();
},
get_bom: function(event) {
var self = this;
var $parent = $(event.currentTarget).closest('tr');
var activeID = $parent.data('id');
var productID = $parent.data('product_id');
var lineID = $parent.data('line');
var qty = $parent.data('qty');
var level = $parent.data('level') || 0;
return this._rpc({
model: 'report.mrp.report_bom_structure',
method: 'get_bom',
args: [
activeID,
productID,
parseFloat(qty),
lineID,
level + 1,
]
})
.then(function (result) {
self.render_html(event, $parent, result);
});
},
get_operations: function(event) {
var self = this;
var $parent = $(event.currentTarget).closest('tr');
var activeID = $parent.data('bom-id');
var qty = $parent.data('qty');
var productId = $parent.data('product_id');
var level = $parent.data('level') || 0;
return this._rpc({
model: 'report.mrp.report_bom_structure',
method: 'get_operations',
args: [
productId,
activeID,
parseFloat(qty),
level + 1
]
})
.then(function (result) {
self.render_html(event, $parent, result);
});
},
get_byproducts: function(event) {
var self = this;
var $parent = $(event.currentTarget).closest('tr');
var activeID = $parent.data('bom-id');
var qty = $parent.data('qty');
var level = $parent.data('level') || 0;
var total = $parent.data('total') || 0;
return this._rpc({
model: 'report.mrp.report_bom_structure',
method: 'get_byproducts',
args: [
activeID,
parseFloat(qty),
level + 1,
parseFloat(total)
]
})
.then(function (result) {
self.render_html(event, $parent, result);
});
},
update_cp: function () {
var status = {
cp_content: {
$buttons: this.$buttonsPanel,
$searchview: this.$searchView
},
};
return this.updateControlPanel(status);
},
getControlPanelProps: function() {
this.renderSearch();
return {
$buttons: this.$buttonsPanel,
$searchview: this.$searchView
}
},
renderSearch: function () {
this.$buttonsPanel = $(QWeb.render('mrp.button', {'is_variant_applied': this.data.is_variant_applied}));
this.$buttonsPanel.find('.o_mrp_bom_print').on('click', this._onClickPrint.bind(this));
this.$buttonsPanel.find('.o_mrp_bom_print_all_variants').on('click', this._onClickPrint.bind(this));
this.$buttonsPanel.find('.o_mrp_bom_unfold_all').on('click', this._onClickUnfoldAll.bind(this));
this.$searchView = $(QWeb.render('mrp.report_bom_search', _.omit(this.data, 'lines')));
this.$searchView.find('.o_mrp_bom_report_qty').on('change', this._onChangeQty.bind(this)).change();
this.$searchView.find('.o_mrp_bom_report_qty').on('keydown', this._onKeyDownQty.bind(this));
this.$searchView.find('.o_mrp_bom_report_variants').on('change', this._onChangeVariants.bind(this)).change();
this.$searchView.find('.o_mrp_bom_report_type').on('change', this._onChangeType.bind(this));
},
_onClickPrint: function (ev) {
var reportname = 'mrp.report_bom_structure?docids=' + this.given_context.active_id +
'&report_type=' + this.given_context.report_type +
'&quantity=' + (this.given_context.searchQty || 1) +
'&unfolded_ids=' + JSON.stringify(this._unfoldedIDs());
if ($(ev.currentTarget).hasClass('o_mrp_bom_print_all_variants')) {
reportname += '&all_variants=' + 1;
} else if (this.given_context.searchVariant) {
reportname += '&variant=' + this.given_context.searchVariant;
}
var action = {
'type': 'ir.actions.report',
'report_type': 'qweb-pdf',
'report_name': reportname,
'report_file': 'mrp.report_bom_structure',
};
return this.do_action(action);
},
_onChangeQty: function (ev) {
var qty = $(ev.currentTarget).val().trim();
if (qty) {
this.given_context.searchQty = parseFloat(qty);
this._reload();
}
},
_onKeyDownQty: function (ev) {
if(ev.which == 13) {
ev.preventDefault();
this._onChangeQty(ev);
}
},
_onChangeType: function (ev) {
var report_type = $("option:selected", $(ev.currentTarget)).data('type');
this.given_context.report_type = report_type;
this._reload_report_type();
},
_onChangeVariants: function (ev) {
this.given_context.searchVariant = $(ev.currentTarget).val();
this._reload();
},
_onClickUnfold: function (ev) {
var redirect_function = $(ev.currentTarget).data('function');
this[redirect_function](ev);
},
_onClickFold: function (ev) {
this._removeLines($(ev.currentTarget).closest('tr'));
$(ev.currentTarget).toggleClass('o_mrp_bom_foldable o_mrp_bom_unfoldable').find('i').toggleClass('fa-caret-right fa-caret-down');
},
_onClickUnfoldAll: function (ev) {
this._unfold();
},
_onClickAction: function (ev) {
ev.preventDefault();
return this.do_action({
type: 'ir.actions.act_window',
res_model: $(ev.currentTarget).data('model'),
res_id: $(ev.currentTarget).data('res-id'),
context: {
'active_id': $(ev.currentTarget).data('res-id')
},
views: [[false, 'form']],
target: 'current'
});
},
_onClickShowAttachment: function (ev) {
ev.preventDefault();
var ids = $(ev.currentTarget).data('res-id');
return this.do_action({
name: _t('Attachments'),
type: 'ir.actions.act_window',
res_model: $(ev.currentTarget).data('model'),
domain: [['id', 'in', ids]],
views: [[false, 'kanban'], [false, 'list'], [false, 'form']],
view_mode: 'kanban,list,form',
target: 'current',
});
},
_reload: function () {
const unfolded_ids = this._unfoldedIDs();
var self = this;
return this.get_html().then(function () {
self.$('.o_content').html(self.data.lines);
self._reload_report_type();
if (unfolded_ids.length) {
self.$('.o_content').css("display", "none");
self._unfold(unfolded_ids);
}
});
},
_reload_report_type: function () {
this.$('.o_mrp_bom_cost.o_hidden, .o_mrp_prod_cost.o_hidden').toggleClass('o_hidden');
if (this.given_context.report_type === 'bom_structure') {
this.$('.o_mrp_bom_cost, .o_mrp_prod_cost').toggleClass('o_hidden');
}
},
_removeLines: function ($el) {
var self = this;
var activeID = $el.data('id');
_.each(this.$('tr[parent_id='+ activeID +']'), function (parent) {
var $parent = self.$(parent);
var $el = self.$('tr[parent_id='+ $parent.data('id') +']');
if ($el.length) {
self._removeLines($parent);
}
$parent.remove();
});
},
_unfoldedIDs: function() {
return _.map(this.$('.o_mrp_bom_foldable').closest('tr'), el => $(el).data('id'));
},
_unfold: function (ids) {
const promises = [];
for (const element of this.el.querySelectorAll('.o_mrp_bom_unfoldable')) {
const id = $(element).closest('tr').data('id');
if (ids && !ids.includes(id)) {
continue;
}
const methodName = element.dataset.function;
promises.push(this[methodName]({currentTarget: element}));
}
Promise.all(promises).then(values => {
if (values.length) {
this._unfold(ids);
} else if (ids) {
this.$('.o_content').css("display", "inherit");
}
});
},
});
core.action_registry.add('mrp_bom_report', MrpBomReport);
export default MrpBomReport;
-40
View File
@@ -1,45 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="mrp.button">
<div class="o_list_buttons o_mrp_bom_report_buttons">
<button type="button" class="btn btn-primary o_mrp_bom_print">Print</button>
<t t-if="is_variant_applied">
<button type="button" class="btn btn-primary o_mrp_bom_print_all_variants">Print All Variants</button>
</t>
<button type="button" class="btn btn-primary o_mrp_bom_unfold_all">Unfold</button>
</div>
</t>
<form class="container-fluid w-lg-75 w-xl-auto pt-lg-3 me-0" t-name="mrp.report_bom_search">
<div class="row gap-3">
<div class="col-12 col-md">
<div class="row">
<label class="col-3 col-xl flex-grow-0">Quantity:</label>
<input type="number" step="any" t-att-value="bom_qty" min="1" class="o_input o_mrp_bom_report_qty col"/>
<div t-if="is_uom_applied" class="col" t-out="bom_uom_name"/>
</div>
</div>
<div t-if="is_variant_applied" class="col-12 col-md">
<div class="row">
<label class="col-3 col-xl flex-grow-0">Variant:</label>
<select class="o_input o_mrp_bom_report_variants col">
<option t-foreach="variants" t-as="variant" t-att-value="variant">
<t t-esc="variants[variant]"/>
</option>
</select>
</div>
</div>
<div t-attf-class="col-12 col-xl">
<div class="row">
<label class="col-3 col-xl flex-grow-0">Report:</label>
<select class="o_input o_mrp_bom_report_type col">
<option t-att-data-type="'all'">BoM Structure &amp; Cost</option>
<option t-att-data-type="'bom_structure'">BoM Structure</option>
</select>
</div>
</div>
</div>
</form>
<div t-name="mrp.workorderPopover">
<t t-foreach="infos" t-as="info">
+31 -34
View File
@@ -538,15 +538,15 @@ class TestBoM(TestMrpCommon):
for component_line in report_values['lines']['components']:
# standard price * bom line quantity * current quantity / bom finished product quantity
if component_line['prod_id'] == butter.id:
if component_line['product'].id == butter.id:
# 5 kg of butter at 7.01$ for 11kg of crumble -> 35.05$
self.assertEqual(float_compare(component_line['total'], (7.01 * 5), precision_digits=2), 0)
if component_line['prod_id'] == biscuit.id:
self.assertEqual(float_compare(component_line['bom_cost'], (7.01 * 5), precision_digits=2), 0)
if component_line['product'].id == biscuit.id:
# 6 kg of biscuits at 1.50$ for 11kg of crumble -> 9$
self.assertEqual(float_compare(component_line['total'], (1.5 * 6), precision_digits=2), 0)
self.assertEqual(float_compare(component_line['bom_cost'], (1.5 * 6), precision_digits=2), 0)
# total price = 35.05 + 9 + operation_cost(23.84) = 67.89
self.assertEqual(float_compare(report_values['lines']['total'], 67.89, precision_digits=2), 0, 'Product Bom Price is not correct')
self.assertEqual(float_compare(report_values['lines']['total'] / 11.0, 6.17, precision_digits=2), 0, 'Product Unit Bom Price is not correct')
self.assertEqual(float_compare(report_values['lines']['bom_cost'], 67.89, precision_digits=2), 0, 'Product Bom Price is not correct')
self.assertEqual(float_compare(report_values['lines']['bom_cost'] / 11.0, 6.17, precision_digits=2), 0, 'Product Unit Bom Price is not correct')
# TEST BOM STRUCTURE VALUE BY UNIT
report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=1, searchVariant=False)
@@ -558,14 +558,14 @@ class TestBoM(TestMrpCommon):
for component_line in report_values['lines']['components']:
# standard price * bom line quantity * current quantity / bom finished product quantity
if component_line['prod_id'] == butter.id:
if component_line['product'].id == butter.id:
# 5 kg of butter at 7.01$ for 11kg of crumble -> / 11 for price per unit (3.19)
self.assertEqual(float_compare(component_line['total'], (7.01 * 5) * (1 / 11), precision_digits=2), 0)
if component_line['prod_id'] == biscuit.id:
self.assertEqual(float_compare(component_line['bom_cost'], (7.01 * 5) * (1 / 11), precision_digits=2), 0)
if component_line['product'].id == biscuit.id:
# 6 kg of biscuits at 1.50$ for 11kg of crumble -> / 11 for price per unit (0.82)
self.assertEqual(float_compare(component_line['total'], (1.5 * 6) * (1 / 11), precision_digits=2), 0)
self.assertEqual(float_compare(component_line['bom_cost'], (1.5 * 6) * (1 / 11), precision_digits=2), 0)
# total price = 3.19 + 0.82 + operation_cost(0.83 + 0.83 + 0.5 = 2.16) = 6,17
self.assertEqual(float_compare(report_values['lines']['total'], 6.17, precision_digits=2), 0, 'Product Unit Bom Price is not correct')
self.assertEqual(float_compare(report_values['lines']['bom_cost'], 6.17, precision_digits=2), 0, 'Product Unit Bom Price is not correct')
# TEST OPERATION COST WHEN PRODUCED QTY > BOM QUANTITY
report_values_12 = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=12, searchVariant=False)
@@ -628,15 +628,15 @@ class TestBoM(TestMrpCommon):
for component_line in report_values['lines']['components']:
# standard price * bom line quantity * current quantity / bom finished product quantity
if component_line['prod_id'] == cream.id:
if component_line['product'].id == cream.id:
# 3 liter of cream at 5.17$ for 60 unit of cheese cake -> 15.51$
self.assertEqual(float_compare(component_line['total'], (3 * 5.17), precision_digits=2), 0)
if component_line['prod_id'] == crumble.id:
self.assertEqual(float_compare(component_line['bom_cost'], (3 * 5.17), precision_digits=2), 0)
if component_line['product'].id == crumble.id:
# 5.4 kg of crumble at the cost of a batch.
crumble_cost = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=5.4, searchVariant=False)['lines']['total']
self.assertEqual(float_compare(component_line['total'], crumble_cost, precision_digits=2), 0)
crumble_cost = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=5.4, searchVariant=False)['lines']['bom_cost']
self.assertEqual(float_compare(component_line['bom_cost'], crumble_cost, precision_digits=2), 0)
# total price = Cream (15.51€) + crumble_cost (34.63 €) + operation_cost(208,33) = 258.47€
self.assertEqual(float_compare(report_values['lines']['total'], 258.47, precision_digits=2), 0, 'Product Bom Price is not correct')
self.assertEqual(float_compare(report_values['lines']['bom_cost'], 258.47, precision_digits=2), 0, 'Product Bom Price is not correct')
def test_bom_report_dozens(self):
""" Simulate a drawer bom with dozens as bom units
@@ -863,39 +863,36 @@ class TestBoM(TestMrpCommon):
# 10l of blue paint
blue_paint = self.paint._get_variant_for_combination(self.paint_color_blue)
self.assertEqual(blue_paint.id, report_values['lines']['components'][0]['prod_id'])
self.assertEqual(report_values['lines']['components'][0]['prod_qty'], 10)
self.assertEqual(blue_paint.id, report_values['lines']['components'][0]['product'].id)
self.assertEqual(report_values['lines']['components'][0]['quantity'], 10)
# 1 blue dashboard with GPS
blue_dashboard_gps = self.dashboard._get_variant_for_combination(self.dashboard_color_blue + self.dashboard_gps_yes)
self.assertEqual(blue_dashboard_gps.id, report_values['lines']['components'][1]['prod_id'])
self.assertEqual(report_values['lines']['components'][1]['prod_qty'], 1)
component = report_values['lines']['components'][1]
report_values_dashboad = self.env['report.mrp.report_bom_structure']._get_bom(
component['child_bom'], component['prod_id'], component['prod_qty'],
component['line_id'], component['level'] + 1)
self.assertEqual(blue_dashboard_gps.id, report_values['lines']['components'][1]['product'].id)
self.assertEqual(report_values['lines']['components'][1]['quantity'], 1)
report_values_dashboad = report_values['lines']['components'][1]
self.assertEqual(len(report_values_dashboad['components']), 2)
self.assertEqual(blue_paint.id, report_values_dashboad['components'][0]['prod_id'])
self.assertEqual(self.gps.id, report_values_dashboad['components'][1]['prod_id'])
self.assertEqual(blue_paint.id, report_values_dashboad['components'][0]['product'].id)
self.assertEqual(self.gps.id, report_values_dashboad['components'][1]['product'].id)
# 0.5l of paint at price of 20$/litre -> 10$
self.assertEqual(report_values_dashboad['components'][0]['total'], 10)
self.assertEqual(report_values_dashboad['components'][0]['bom_cost'], 10)
# GPS 700$
self.assertEqual(report_values_dashboad['components'][1]['total'], 700)
self.assertEqual(report_values_dashboad['components'][1]['bom_cost'], 700)
# Dashboard blue with GPS should have a BoM cost of 710$
self.assertEqual(report_values['lines']['components'][1]['total'], 710)
self.assertEqual(report_values['lines']['components'][1]['bom_cost'], 710)
# 10l of paint at price of 20$/litre -> 200$
self.assertEqual(report_values['lines']['components'][0]['total'], 200)
self.assertEqual(report_values['lines']['components'][0]['bom_cost'], 200)
# Total cost of blue car with GPS: 10 + 700 + 200 = 910
self.assertEqual(report_values['lines']['total'], 910)
self.assertEqual(report_values['lines']['bom_cost'], 910)
red_car_without_gps = self.car._get_variant_for_combination(self.car_color_red + self.car_gps_no)
report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_car.id, searchQty=1, searchVariant=red_car_without_gps.id)
# Same math than before but without GPS
self.assertEqual(report_values['lines']['total'], 210)
self.assertEqual(report_values['lines']['bom_cost'], 210)
def test_22_bom_report_recursive_bom(self):
""" Test report with recursive BoM and different quantities.
@@ -981,7 +978,7 @@ class TestBoM(TestMrpCommon):
report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_finished.id, searchQty=80)
self.assertAlmostEqual(report_values['lines']['total'], 0.58)
self.assertAlmostEqual(report_values['lines']['bom_cost'], 2.92)
def test_validate_no_bom_line_with_same_product(self):
"""
+2 -2
View File
@@ -3,7 +3,7 @@
<data>
<!-- Bill of Materials -->
<record id="action_report_mrp_bom" model="ir.actions.client">
<field name="name">BoM Structure &amp; Cost</field>
<field name="name">BoM Overview</field>
<field name="tag">mrp_bom_report</field>
<field name="context" eval="{'model': 'report.mrp.report_bom_structure'}" />
</record>
@@ -45,7 +45,7 @@
</div>
</button>
<button name="%(action_report_mrp_bom)d" type="action"
class="oe_stat_button" icon="fa-bars" string="Structure &amp; Cost"/>
class="oe_stat_button" icon="fa-bars" string="Overview"/>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<group>