product.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import collections
  4. from datetime import timedelta
  5. from itertools import groupby
  6. import operator as py_operator
  7. from odoo import fields, models, _
  8. from odoo.tools import groupby
  9. from odoo.tools.float_utils import float_round, float_is_zero
  10. OPERATORS = {
  11. '<': py_operator.lt,
  12. '>': py_operator.gt,
  13. '<=': py_operator.le,
  14. '>=': py_operator.ge,
  15. '=': py_operator.eq,
  16. '!=': py_operator.ne
  17. }
  18. class ProductTemplate(models.Model):
  19. _inherit = "product.template"
  20. bom_line_ids = fields.One2many('mrp.bom.line', 'product_tmpl_id', 'BoM Components')
  21. bom_ids = fields.One2many('mrp.bom', 'product_tmpl_id', 'Bill of Materials')
  22. bom_count = fields.Integer('# Bill of Material',
  23. compute='_compute_bom_count', compute_sudo=False)
  24. used_in_bom_count = fields.Integer('# of BoM Where is Used',
  25. compute='_compute_used_in_bom_count', compute_sudo=False)
  26. mrp_product_qty = fields.Float('Manufactured', digits='Product Unit of Measure',
  27. compute='_compute_mrp_product_qty', compute_sudo=False)
  28. produce_delay = fields.Float(
  29. 'Manufacturing Lead Time', default=0.0,
  30. help="Average lead time in days to manufacture this product. In the case of multi-level BOM, the manufacturing lead times of the components will be added. In case the product is subcontracted, this can be used to determine the date at which components should be sent to the subcontractor.")
  31. is_kits = fields.Boolean(compute='_compute_is_kits', compute_sudo=False)
  32. days_to_prepare_mo = fields.Float(
  33. string="Days to prepare Manufacturing Order", default=0.0,
  34. help="Create and confirm Manufacturing Orders this many days in advance, to have enough time to replenish components or manufacture semi-finished products.\n"
  35. "Note that security lead times will also be considered when appropriate.")
  36. def _compute_bom_count(self):
  37. for product in self:
  38. product.bom_count = self.env['mrp.bom'].search_count(['|', ('product_tmpl_id', '=', product.id), ('byproduct_ids.product_id.product_tmpl_id', '=', product.id)])
  39. def _compute_is_kits(self):
  40. domain = [('product_tmpl_id', 'in', self.ids), ('type', '=', 'phantom')]
  41. bom_mapping = self.env['mrp.bom'].search_read(domain, ['product_tmpl_id'])
  42. kits_ids = set(b['product_tmpl_id'][0] for b in bom_mapping)
  43. for template in self:
  44. template.is_kits = (template.id in kits_ids)
  45. def _compute_show_qty_status_button(self):
  46. super()._compute_show_qty_status_button()
  47. for template in self:
  48. if template.is_kits:
  49. template.show_on_hand_qty_status_button = template.product_variant_count <= 1
  50. template.show_forecasted_qty_status_button = False
  51. def _compute_used_in_bom_count(self):
  52. for template in self:
  53. template.used_in_bom_count = self.env['mrp.bom'].search_count(
  54. [('bom_line_ids.product_tmpl_id', '=', template.id)])
  55. def write(self, values):
  56. if 'active' in values:
  57. self.filtered(lambda p: p.active != values['active']).with_context(active_test=False).bom_ids.write({
  58. 'active': values['active']
  59. })
  60. return super().write(values)
  61. def action_used_in_bom(self):
  62. self.ensure_one()
  63. action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_bom_form_action")
  64. action['domain'] = [('bom_line_ids.product_tmpl_id', '=', self.id)]
  65. return action
  66. def _compute_mrp_product_qty(self):
  67. for template in self:
  68. template.mrp_product_qty = float_round(sum(template.mapped('product_variant_ids').mapped('mrp_product_qty')), precision_rounding=template.uom_id.rounding)
  69. def action_view_mos(self):
  70. action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_production_report")
  71. action['domain'] = [('state', '=', 'done'), ('product_tmpl_id', 'in', self.ids)]
  72. action['context'] = {
  73. 'graph_measure': 'product_uom_qty',
  74. 'search_default_filter_plan_date': 1,
  75. }
  76. return action
  77. def action_compute_bom_days(self):
  78. templates = self.filtered(lambda t: t.bom_count > 0)
  79. if templates:
  80. return templates.mapped('product_variant_id').action_compute_bom_days()
  81. def action_archive(self):
  82. filtered_products = self.env['mrp.bom.line'].search([('product_id', 'in', self.product_variant_ids.ids), ('bom_id.active', '=', True)]).product_id.mapped('display_name')
  83. res = super().action_archive()
  84. if filtered_products:
  85. return {
  86. 'type': 'ir.actions.client',
  87. 'tag': 'display_notification',
  88. 'params': {
  89. 'title': _("Note that product(s): '%s' is/are still linked to active Bill of Materials, "
  90. "which means that the product can still be used on it/them.", filtered_products),
  91. 'type': 'warning',
  92. 'sticky': True, #True/False will display for few seconds if false
  93. 'next': {'type': 'ir.actions.act_window_close'},
  94. },
  95. }
  96. return res
  97. class ProductProduct(models.Model):
  98. _inherit = "product.product"
  99. variant_bom_ids = fields.One2many('mrp.bom', 'product_id', 'BOM Product Variants')
  100. bom_line_ids = fields.One2many('mrp.bom.line', 'product_id', 'BoM Components')
  101. bom_count = fields.Integer('# Bill of Material',
  102. compute='_compute_bom_count', compute_sudo=False)
  103. used_in_bom_count = fields.Integer('# BoM Where Used',
  104. compute='_compute_used_in_bom_count', compute_sudo=False)
  105. mrp_product_qty = fields.Float('Manufactured', digits='Product Unit of Measure',
  106. compute='_compute_mrp_product_qty', compute_sudo=False)
  107. is_kits = fields.Boolean(compute="_compute_is_kits", compute_sudo=False)
  108. def _compute_bom_count(self):
  109. for product in self:
  110. product.bom_count = self.env['mrp.bom'].search_count(['|', '|', ('byproduct_ids.product_id', '=', product.id), ('product_id', '=', product.id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product.product_tmpl_id.id)])
  111. def _compute_is_kits(self):
  112. domain = ['&', ('type', '=', 'phantom'),
  113. '|', ('product_id', 'in', self.ids),
  114. '&', ('product_id', '=', False),
  115. ('product_tmpl_id', 'in', self.product_tmpl_id.ids)]
  116. bom_mapping = self.env['mrp.bom'].search_read(domain, ['product_tmpl_id', 'product_id'])
  117. kits_template_ids = set([])
  118. kits_product_ids = set([])
  119. for bom_data in bom_mapping:
  120. if bom_data['product_id']:
  121. kits_product_ids.add(bom_data['product_id'][0])
  122. else:
  123. kits_template_ids.add(bom_data['product_tmpl_id'][0])
  124. for product in self:
  125. product.is_kits = (product.id in kits_product_ids or product.product_tmpl_id.id in kits_template_ids)
  126. def _compute_show_qty_status_button(self):
  127. super()._compute_show_qty_status_button()
  128. for product in self:
  129. if product.is_kits:
  130. product.show_on_hand_qty_status_button = True
  131. product.show_forecasted_qty_status_button = False
  132. def _compute_used_in_bom_count(self):
  133. for product in self:
  134. product.used_in_bom_count = self.env['mrp.bom'].search_count([('bom_line_ids.product_id', '=', product.id)])
  135. def write(self, values):
  136. if 'active' in values:
  137. self.filtered(lambda p: p.active != values['active']).with_context(active_test=False).variant_bom_ids.write({
  138. 'active': values['active']
  139. })
  140. return super().write(values)
  141. def get_components(self):
  142. """ Return the components list ids in case of kit product.
  143. Return the product itself otherwise"""
  144. self.ensure_one()
  145. bom_kit = self.env['mrp.bom']._bom_find(self, bom_type='phantom')[self]
  146. if bom_kit:
  147. boms, bom_sub_lines = bom_kit.explode(self, 1)
  148. return [bom_line.product_id.id for bom_line, data in bom_sub_lines if bom_line.product_id.type == 'product']
  149. else:
  150. return super(ProductProduct, self).get_components()
  151. def action_used_in_bom(self):
  152. self.ensure_one()
  153. action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_bom_form_action")
  154. action['domain'] = [('bom_line_ids.product_id', '=', self.id)]
  155. return action
  156. def _compute_mrp_product_qty(self):
  157. date_from = fields.Datetime.to_string(fields.datetime.now() - timedelta(days=365))
  158. #TODO: state = done?
  159. domain = [('state', '=', 'done'), ('product_id', 'in', self.ids), ('date_planned_start', '>', date_from)]
  160. read_group_res = self.env['mrp.production']._read_group(domain, ['product_id', 'product_uom_qty'], ['product_id'])
  161. mapped_data = dict([(data['product_id'][0], data['product_uom_qty']) for data in read_group_res])
  162. for product in self:
  163. if not product.id:
  164. product.mrp_product_qty = 0.0
  165. continue
  166. product.mrp_product_qty = float_round(mapped_data.get(product.id, 0), precision_rounding=product.uom_id.rounding)
  167. def _compute_quantities_dict(self, lot_id, owner_id, package_id, from_date=False, to_date=False):
  168. """ When the product is a kit, this override computes the fields :
  169. - 'virtual_available'
  170. - 'qty_available'
  171. - 'incoming_qty'
  172. - 'outgoing_qty'
  173. - 'free_qty'
  174. This override is used to get the correct quantities of products
  175. with 'phantom' as BoM type.
  176. """
  177. bom_kits = self.env['mrp.bom']._bom_find(self, bom_type='phantom')
  178. kits = self.filtered(lambda p: bom_kits.get(p))
  179. regular_products = self - kits
  180. res = (
  181. super(ProductProduct, regular_products)._compute_quantities_dict(lot_id, owner_id, package_id, from_date=from_date, to_date=to_date)
  182. if regular_products
  183. else {}
  184. )
  185. qties = self.env.context.get("mrp_compute_quantities", {})
  186. qties.update(res)
  187. # pre-compute bom lines and identify missing kit components to prefetch
  188. bom_sub_lines_per_kit = {}
  189. prefetch_component_ids = set()
  190. for product in bom_kits:
  191. __, bom_sub_lines = bom_kits[product].explode(product, 1)
  192. bom_sub_lines_per_kit[product] = bom_sub_lines
  193. for bom_line, __ in bom_sub_lines:
  194. if bom_line.product_id.id not in qties:
  195. prefetch_component_ids.add(bom_line.product_id.id)
  196. # compute kit quantities
  197. for product in bom_kits:
  198. bom_sub_lines = bom_sub_lines_per_kit[product]
  199. # group lines by component
  200. bom_sub_lines_grouped = collections.defaultdict(list)
  201. for info in bom_sub_lines:
  202. bom_sub_lines_grouped[info[0].product_id].append(info)
  203. ratios_virtual_available = []
  204. ratios_qty_available = []
  205. ratios_incoming_qty = []
  206. ratios_outgoing_qty = []
  207. ratios_free_qty = []
  208. for component, bom_sub_lines in bom_sub_lines_grouped.items():
  209. component = component.with_context(mrp_compute_quantities=qties).with_prefetch(prefetch_component_ids)
  210. qty_per_kit = 0
  211. for bom_line, bom_line_data in bom_sub_lines:
  212. if component.type != 'product' or float_is_zero(bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding):
  213. # As BoMs allow components with 0 qty, a.k.a. optionnal components, we simply skip those
  214. # to avoid a division by zero. The same logic is applied to non-storable products as those
  215. # products have 0 qty available.
  216. continue
  217. uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
  218. qty_per_kit += bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id, round=False, raise_if_failure=False)
  219. if not qty_per_kit:
  220. continue
  221. rounding = component.uom_id.rounding
  222. component_res = (
  223. qties.get(component.id)
  224. if component.id in qties
  225. else {
  226. "virtual_available": float_round(component.virtual_available, precision_rounding=rounding),
  227. "qty_available": float_round(component.qty_available, precision_rounding=rounding),
  228. "incoming_qty": float_round(component.incoming_qty, precision_rounding=rounding),
  229. "outgoing_qty": float_round(component.outgoing_qty, precision_rounding=rounding),
  230. "free_qty": float_round(component.free_qty, precision_rounding=rounding),
  231. }
  232. )
  233. ratios_virtual_available.append(component_res["virtual_available"] / qty_per_kit)
  234. ratios_qty_available.append(component_res["qty_available"] / qty_per_kit)
  235. ratios_incoming_qty.append(component_res["incoming_qty"] / qty_per_kit)
  236. ratios_outgoing_qty.append(component_res["outgoing_qty"] / qty_per_kit)
  237. ratios_free_qty.append(component_res["free_qty"] / qty_per_kit)
  238. if bom_sub_lines and ratios_virtual_available: # Guard against all cnsumable bom: at least one ratio should be present.
  239. res[product.id] = {
  240. 'virtual_available': min(ratios_virtual_available) * bom_kits[product].product_qty // 1,
  241. 'qty_available': min(ratios_qty_available) * bom_kits[product].product_qty // 1,
  242. 'incoming_qty': min(ratios_incoming_qty) * bom_kits[product].product_qty // 1,
  243. 'outgoing_qty': min(ratios_outgoing_qty) * bom_kits[product].product_qty // 1,
  244. 'free_qty': min(ratios_free_qty) * bom_kits[product].product_qty // 1,
  245. }
  246. else:
  247. res[product.id] = {
  248. 'virtual_available': 0,
  249. 'qty_available': 0,
  250. 'incoming_qty': 0,
  251. 'outgoing_qty': 0,
  252. 'free_qty': 0,
  253. }
  254. return res
  255. def action_view_bom(self):
  256. action = self.env["ir.actions.actions"]._for_xml_id("mrp.product_open_bom")
  257. template_ids = self.mapped('product_tmpl_id').ids
  258. # bom specific to this variant or global to template or that contains the product as a byproduct
  259. action['context'] = {
  260. 'default_product_tmpl_id': template_ids[0],
  261. 'default_product_id': self.ids[0],
  262. }
  263. action['domain'] = ['|', '|', ('byproduct_ids.product_id', 'in', self.ids), ('product_id', 'in', self.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', template_ids)]
  264. return action
  265. def action_view_mos(self):
  266. action = self.product_tmpl_id.action_view_mos()
  267. action['domain'] = [('state', '=', 'done'), ('product_id', 'in', self.ids)]
  268. return action
  269. def action_open_quants(self):
  270. bom_kits = self.env['mrp.bom']._bom_find(self, bom_type='phantom')
  271. components = self - self.env['product.product'].concat(*list(bom_kits.keys()))
  272. for product in bom_kits:
  273. boms, bom_sub_lines = bom_kits[product].explode(product, 1)
  274. components |= self.env['product.product'].concat(*[l[0].product_id for l in bom_sub_lines])
  275. res = super(ProductProduct, components).action_open_quants()
  276. if bom_kits:
  277. res['context']['single_product'] = False
  278. res['context'].pop('default_product_tmpl_id', None)
  279. return res
  280. def action_compute_bom_days(self):
  281. bom_by_products = self.env['mrp.bom']._bom_find(self)
  282. company_id = self.env.context.get('default_company_id', self.env.company.id)
  283. warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1)
  284. for product in self:
  285. bom_data = self.env['report.mrp.report_bom_structure'].with_context(minimized=True)._get_bom_data(bom_by_products[product], warehouse, product, ignore_stock=True)
  286. availability_delay = bom_data.get('resupply_avail_delay')
  287. product.days_to_prepare_mo = availability_delay - bom_data.get('lead_time', 0) if availability_delay else 0
  288. def _match_all_variant_values(self, product_template_attribute_value_ids):
  289. """ It currently checks that all variant values (`product_template_attribute_value_ids`)
  290. are in the product (`self`).
  291. If multiple values are encoded for the same attribute line, only one of
  292. them has to be found on the variant.
  293. """
  294. self.ensure_one()
  295. # The intersection of the values of the product and those of the line satisfy:
  296. # * the number of items equals the number of attributes (since a product cannot
  297. # have multiple values for the same attribute),
  298. # * the attributes are a subset of the attributes of the line.
  299. return len(self.product_template_attribute_value_ids & product_template_attribute_value_ids) == len(product_template_attribute_value_ids.attribute_id)
  300. def _count_returned_sn_products(self, sn_lot):
  301. res = self.env['stock.move.line'].search_count([
  302. ('lot_id', '=', sn_lot.id),
  303. ('qty_done', '=', 1),
  304. ('state', '=', 'done'),
  305. ('production_id', '=', False),
  306. ('location_id.usage', '=', 'production'),
  307. ('move_id.unbuild_id', '!=', False),
  308. ])
  309. return super()._count_returned_sn_products(sn_lot) + res
  310. def _search_qty_available_new(self, operator, value, lot_id=False, owner_id=False, package_id=False):
  311. '''extending the method in stock.product to take into account kits'''
  312. product_ids = super(ProductProduct, self)._search_qty_available_new(operator, value, lot_id, owner_id, package_id)
  313. kit_boms = self.env['mrp.bom'].search([('type', "=", 'phantom')])
  314. kit_products = self.env['product.product']
  315. for kit in kit_boms:
  316. if kit.product_id:
  317. kit_products |= kit.product_id
  318. else:
  319. kit_products |= kit.product_tmpl_id.product_variant_ids
  320. for product in kit_products:
  321. if OPERATORS[operator](product.qty_available, value):
  322. product_ids.append(product.id)
  323. return list(set(product_ids))
  324. def action_archive(self):
  325. filtered_products = self.env['mrp.bom.line'].search([('product_id', 'in', self.ids), ('bom_id.active', '=', True)]).product_id.mapped('display_name')
  326. res = super().action_archive()
  327. if filtered_products:
  328. return {
  329. 'type': 'ir.actions.client',
  330. 'tag': 'display_notification',
  331. 'params': {
  332. 'title': _("Note that product(s): '%s' is/are still linked to active Bill of Materials, "
  333. "which means that the product can still be used on it/them.", filtered_products),
  334. 'type': 'warning',
  335. 'sticky': True, #True/False will display for few seconds if false
  336. 'next': {'type': 'ir.actions.act_window_close'},
  337. },
  338. }
  339. return res