diff --git a/mrp_bom_dismantling/README.rst b/mrp_bom_dismantling/README.rst new file mode 100644 index 000000000..ff3c7b758 --- /dev/null +++ b/mrp_bom_dismantling/README.rst @@ -0,0 +1,59 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +=============== +BOM Dismantling +=============== + +This module adds the ability to create a dismantling BOM by reversing a BOM. + + +Usage +===== + + +* On BOM form view, click on "Create dismantling BOM" button and it will reverse your BOM. +* In Manufacturing -> Products, there is a new menu "Dismantling". +* In dismantling tree view, you can search by dismantled product. +* On BOM form view, there is a new button "Create Manufacturing Order". + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/129/9.0 + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed `feedback +`_. + +Credits +======= + +Contributors +------------ + +* Camptocamp - Cyril Gaudin + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/mrp_bom_dismantling/__init__.py b/mrp_bom_dismantling/__init__.py new file mode 100644 index 000000000..83c963b2a --- /dev/null +++ b/mrp_bom_dismantling/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models +from . import wizards diff --git a/mrp_bom_dismantling/__openerp__.py b/mrp_bom_dismantling/__openerp__.py new file mode 100644 index 000000000..53f0dc284 --- /dev/null +++ b/mrp_bom_dismantling/__openerp__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "BOM Dismantling", + "summary": "Ability to create a dismantling BOM by reversing a BOM.", + "version": "9.0.1.0.0", + "category": "Manufacturing", + "website": "http://www.camptocamp.com/", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + 'mrp_byproduct', + "stock_available_mrp", + ], + "data": [ + "views/mrp_bom.xml", + "views/product_template.xml", + "wizards/mrp_product_produce.xml", + ], +} diff --git a/mrp_bom_dismantling/i18n/de.po b/mrp_bom_dismantling/i18n/de.po new file mode 100644 index 000000000..978723f1d --- /dev/null +++ b/mrp_bom_dismantling/i18n/de.po @@ -0,0 +1,60 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mrp_bom_dismantling +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-04-04 11:23+0200\n" +"PO-Revision-Date: 2016-04-06 15:55+0200\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 1.8.4\n" +"Last-Translator: \n" +"Language: de\n" + +#. module: mrp_bom_dismantling +#: model:ir.model,name:mrp_bom_dismantling.model_mrp_bom +msgid "Bill of Material" +msgstr "Stücklisten" + +#. module: mrp_bom_dismantling +#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view +msgid "Create Manufacturing Order" +msgstr "Fertigungsauftrag erstellen" + +#. module: mrp_bom_dismantling +#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view +msgid "Create dismantling BoM" +msgstr "Stücklisten umrüsten erstellen" + +#. module: mrp_bom_dismantling +#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantled_product_id +msgid "Dismantled product" +msgstr "Produkt zu umrüsten" + +#. module: mrp_bom_dismantling +#: model:ir.actions.act_window,name:mrp_bom_dismantling.mrp_bom_dismantling_form_action +#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantling +#: model:ir.ui.menu,name:mrp_bom_dismantling.menu_mrp_bom_dismantling +msgid "Dismantling" +msgstr "Umrüsten" + +#. module: mrp_bom_dismantling +#: sql_constraint:mrp.bom:0 +msgid "Dismantling BoM should have a dismantled product." +msgstr "" + +#. module: mrp_bom_dismantling +#: model:ir.model,name:mrp_bom_dismantling.model_product_product +msgid "Product" +msgstr "Produkt" + +#. module: mrp_bom_dismantling +#: model:ir.model,name:mrp_bom_dismantling.model_product_template +msgid "Product Template" +msgstr "Produktvorlage" diff --git a/mrp_bom_dismantling/i18n/mrp_bom_dismantling.pot b/mrp_bom_dismantling/i18n/mrp_bom_dismantling.pot new file mode 100644 index 000000000..e0869f69f --- /dev/null +++ b/mrp_bom_dismantling/i18n/mrp_bom_dismantling.pot @@ -0,0 +1,59 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mrp_bom_dismantling +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 9.0c\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-04-04 09:17+0000\n" +"PO-Revision-Date: 2016-04-04 09:17+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mrp_bom_dismantling +#: model:ir.model,name:mrp_bom_dismantling.model_mrp_bom +msgid "Bill of Material" +msgstr "" + +#. module: mrp_bom_dismantling +#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view +msgid "Create Manufacturing Order" +msgstr "" + +#. module: mrp_bom_dismantling +#: model:ir.ui.view,arch_db:mrp_bom_dismantling.mrp_bom_form_view +msgid "Create dismantling BoM" +msgstr "" + +#. module: mrp_bom_dismantling +#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantled_product_id +msgid "Dismantled product" +msgstr "" + +#. module: mrp_bom_dismantling +#: model:ir.actions.act_window,name:mrp_bom_dismantling.mrp_bom_dismantling_form_action +#: model:ir.model.fields,field_description:mrp_bom_dismantling.field_mrp_bom_dismantling +#: model:ir.ui.menu,name:mrp_bom_dismantling.menu_mrp_bom_dismantling +msgid "Dismantling" +msgstr "" + +#. module: mrp_bom_dismantling +#: sql_constraint:mrp.bom:0 +msgid "Dismantling BoM should have a dismantled product." +msgstr "" + +#. module: mrp_bom_dismantling +#: model:ir.model,name:mrp_bom_dismantling.model_product_product +msgid "Product" +msgstr "" + +#. module: mrp_bom_dismantling +#: model:ir.model,name:mrp_bom_dismantling.model_product_template +msgid "Product Template" +msgstr "" + diff --git a/mrp_bom_dismantling/models/__init__.py b/mrp_bom_dismantling/models/__init__.py new file mode 100644 index 000000000..07cc00ac6 --- /dev/null +++ b/mrp_bom_dismantling/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import mrp_bom +from . import product_product +from . import product_template +from . import stock_move diff --git a/mrp_bom_dismantling/models/mrp_bom.py b/mrp_bom_dismantling/models/mrp_bom.py new file mode 100644 index 000000000..991df667c --- /dev/null +++ b/mrp_bom_dismantling/models/mrp_bom.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp import _, api, exceptions, fields, models + + +class MrpBom(models.Model): + _inherit = 'mrp.bom' + + dismantling = fields.Boolean(string='Dismantling', default=False) + dismantled_product_id = fields.Many2one( + comodel_name='product.product', + string='Dismantled product' + ) + + _sql_constraints = [ + ('bom_dismantled_product_id', + 'CHECK(dismantled_product_id is not null = dismantling)', + "Dismantling BoM should have a dismantled product."), + ] + + @api.multi + def create_mrp_production(self): + """ Create a manufacturing order from this BoM + """ + self.ensure_one() + + product = self._get_bom_product() + + production = self.env['mrp.production'].create({ + 'bom_id': self.id, + 'product_id': product.id, + 'product_qty': self.product_qty, + 'product_uom': self.product_uom.id, + }) + + return self._get_form_view('mrp.production', production) + + @api.multi + def create_dismantling_bom(self): + """ Create a dismantling BoM based on this BoM + """ + self.ensure_one() + + self._check_bom_validity(check_dismantling=True) + + product = self._get_bom_product() + components = self._get_components_tuples() + + # Create the BoM on first component (sorted by Id) + first_component, first_component_needs = components.pop(0) + dismantling_bom = self.create({ + 'product_tmpl_id': first_component.product_tmpl_id.id, + 'product_id': first_component.id, + 'dismantling': True, + 'dismantled_product_id': product.id, + 'product_qty': first_component_needs, + }) + + # Create BoM line for self.product_tmpl_id + self.env['mrp.bom.line'].create({ + 'bom_id': dismantling_bom.id, + 'product_id': product.id, + 'product_qty': self.product_qty, + 'product_uom': self.product_uom.id, + }) + + # Add others component as By-products + subproduct_model = self.env['mrp.subproduct'] + for component, needs in components: + subproduct_model.create({ + 'bom_id': dismantling_bom.id, + 'product_id': component.id, + 'product_qty': needs, + 'product_uom': self.env.ref('product.product_uom_unit').id, + }) + + return self._get_form_view('mrp.bom', dismantling_bom) + + def _get_form_view(self, model_name, entity): + return { + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': model_name, + 'target': 'current', + 'res_id': entity.id, + 'context': self.env.context + } + + def _check_bom_validity(self, check_dismantling=False): + """ Ensure this BoM can be use for creating a dismantling BoM + or a manufacturing order. + + :type check_dismantling: bool + :raise exceptions.UserError: If this BoM is not valid. + """ + warning = None + if check_dismantling and self.dismantling: + warning = 'This BoM is already a dismantling Bom.' + + if not len(self.bom_line_ids): + warning = 'This BoM does not have components.' + + if not self.product_id \ + and len(self.product_tmpl_id.product_variant_ids) > 1: + warning = 'This product has several variants: ' \ + 'you need to specify one.' + + if warning: + raise exceptions.UserError(_(warning)) + + def _get_components_tuples(self): + """ Return this BoM components and their needed qties + sorted by component id. + + The result is like [(component_1, 1), (component_2, 5), ...] + + :rtype: list of tuple + """ + components = self.product_id._get_components_needs( + product=self.product_id, bom=self + ) + components = sorted(components.items(), key=lambda t: t[0].id) + return components + + def _get_bom_product(self): + """ Get the product of this BoM. + + If BoM does not have product_id, return first template variant. + + :rtype: product_product + """ + if not self.product_id: + product = self.product_tmpl_id.product_variant_ids[0] + else: + product = self.product_id + return product diff --git a/mrp_bom_dismantling/models/product_product.py b/mrp_bom_dismantling/models/product_product.py new file mode 100644 index 000000000..3dbc83ff4 --- /dev/null +++ b/mrp_bom_dismantling/models/product_product.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp import models + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + def action_view_bom(self, cr, uid, ids, context=None): + """ Override parent method to add a domain which filter out + dismantling BoM + """ + result = super(ProductProduct, self).action_view_bom( + cr, uid, ids, context + ) + result['domain'] = [('dismantling', '=', False)] + return result diff --git a/mrp_bom_dismantling/models/product_template.py b/mrp_bom_dismantling/models/product_template.py new file mode 100644 index 000000000..d0ea7c71e --- /dev/null +++ b/mrp_bom_dismantling/models/product_template.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + bom_count = fields.Integer(compute='_bom_count', + string='# Bill of Material') + + @api.multi + def _bom_count(self): + """ Override parent method to filter out dismantling bom. + """ + for template in self: + template.bom_count = self.env['mrp.bom'].search_count([ + ('product_tmpl_id', '=', template.id), + ('dismantling', '=', False), + ]) diff --git a/mrp_bom_dismantling/models/stock_move.py b/mrp_bom_dismantling/models/stock_move.py new file mode 100644 index 000000000..64e4e743d --- /dev/null +++ b/mrp_bom_dismantling/models/stock_move.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp import api, models + + +class StockMove(models.Model): + _inherit = 'stock.move' + + @api.multi + def action_consume(self, product_qty, location_id=False, + restrict_lot_id=False, restrict_partner_id=False, + consumed_for=False): + """ Override restrict_lot_id if user define one for this move's + product in wizard. + """ + # If user define a lot_id for this move's product we override + restrict_lot_id = self.env.context.get('mapping_move_lot', {}).pop( + self.id, restrict_lot_id + ) + + return super(StockMove, self).action_consume( + product_qty, location_id, restrict_lot_id, + restrict_partner_id, consumed_for + ) diff --git a/mrp_bom_dismantling/tests/__init__.py b/mrp_bom_dismantling/tests/__init__.py new file mode 100644 index 000000000..66be24cc2 --- /dev/null +++ b/mrp_bom_dismantling/tests/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from . import test_bom +from . import test_product +from . import test_product_produce +from . import test_template diff --git a/mrp_bom_dismantling/tests/test_bom.py b/mrp_bom_dismantling/tests/test_bom.py new file mode 100644 index 000000000..f53c6469b --- /dev/null +++ b/mrp_bom_dismantling/tests/test_bom.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp import exceptions +from openerp.tests import TransactionCase + + +class TestBom(TransactionCase): + + def setUp(self): + super(TestBom, self).setUp() + + self.product_model = self.env['product.product'] + self.bom_model = self.env['mrp.bom'] + self.bom_line_model = self.env['mrp.bom.line'] + self.mrp_production_model = self.env['mrp.production'] + + self.unit_uom = self.browse_ref('product.product_uom_unit') + self.dozen_uom = self.browse_ref('product.product_uom_dozen') + + def check_result_and_load_entity(self, model_name, result): + entity_id = result.pop('res_id') + self.assertEqual({ + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'form', + 'res_model': model_name, + 'target': 'current', + 'context': self.env.context + }, result) + + return self.env[model_name].browse(entity_id) + + def create_bom(self, product, qty=1, uom=None, + phantom=False, components=None): + bom = self.bom_model.create({ + 'product_tmpl_id': product.product_tmpl_id.id, + 'product_id': product.id, + 'product_qty': qty, + 'product_uom': self.unit_uom.id if uom is None else uom.id, + 'type': 'phantom' if phantom else 'normal', + }) + + if components: + for component in components: + self.create_bom_line(bom, component) + return bom + + def create_bom_line(self, bom, component, qty=1, uom=None): + self.bom_line_model.create({ + 'bom_id': bom.id, + 'product_id': component.id, + 'product_qty': qty, + 'product_uom': self.unit_uom.id if uom is None else uom.id, + }) + + def test_dismantling_no_components(self): + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P1'}) + + p1_bom = self.create_bom(p1) + + with self.assertRaises(exceptions.UserError): + p1_bom.create_dismantling_bom() + + self.create_bom_line(p1_bom, p2) + p1_bom.create_dismantling_bom() + + def test_dismantling_on_dismantling_bom(self): + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P1'}) + + p1_bom = self.create_bom(p1, components=[p2]) + p1_bom.write({ + 'dismantling': True, + 'dismantled_product_id': p2.id + }) + + with self.assertRaises(exceptions.UserError): + p1_bom.create_dismantling_bom() + + def test_dismantling_bom_no_product_id__multiple_vaiants(self): + p1 = self.product_model.create({'name': 'Test P1'}) + p1_var = self.product_model.create({ + 'product_tmpl_id': p1.product_tmpl_id.id, + }) + p2 = self.product_model.create({'name': 'Test P2'}) + + # P1 BoM: Need one P2 + p1_bom = self.bom_model.create({ + 'product_tmpl_id': p1.product_tmpl_id.id, + 'product_id': None, + }) + self.create_bom_line(p1_bom, p2) + + # No variant specified (and template has multiple variants) + with self.assertRaises(exceptions.UserError): + p1_bom.create_dismantling_bom() + + # Variant specified + p1_bom.product_id = p1_var + result = p1_bom.create_dismantling_bom() + self.check_result_and_load_entity('mrp.bom', result) + + def test_dismantling_bom_no_product_id(self): + # Same tests but BoM only have a product_tmpl_id, no product_id + # (Seems to be the standard case) + + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P2'}) + + # P1 BoM: Need one P2 + p1_bom = self.bom_model.create({ + 'product_tmpl_id': p1.product_tmpl_id.id, + 'product_id': None, + }) + self.create_bom_line(p1_bom, p2) + + result = p1_bom.create_dismantling_bom() + + dmtl_bom = self.check_result_and_load_entity('mrp.bom', result) + self.assertEqual(p2.id, dmtl_bom.product_id.id) + self.assertEqual(True, dmtl_bom.dismantling) + self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id) + self.assertEqual(p2.product_tmpl_id.id, dmtl_bom.product_tmpl_id.id) + + # Consume p1 + self.assertEqual(1, len(dmtl_bom.bom_line_ids)) + self.assertEqual(p1.id, dmtl_bom.bom_line_ids[0].product_id.id) + + def test_dismantling_simple_case(self): + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P2'}) + p3 = self.product_model.create({'name': 'Test P3'}) + + # P1 BoM: Need one P2 and one P3 + p1_bom = self.create_bom(p1, components=[p2, p3]) + + result = p1_bom.create_dismantling_bom() + + dmtl_bom = self.check_result_and_load_entity('mrp.bom', result) + self.assertEqual(p2.id, dmtl_bom.product_id.id) + self.assertEqual(p2.product_tmpl_id.id, dmtl_bom.product_tmpl_id.id) + self.assertEqual(True, dmtl_bom.dismantling) + self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id) + + # Consume p1 + self.assertEqual(1, len(dmtl_bom.bom_line_ids)) + self.assertEqual(p1.id, dmtl_bom.bom_line_ids[0].product_id.id) + + # P3 in by-products + self.assertEqual(1, len(dmtl_bom.sub_products)) + self.assertEqual(p3.id, dmtl_bom.sub_products[0].product_id.id) + + def test_phantom_bom(self): + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P2'}) + sub_p2 = self.product_model.create({'name': 'Test sub P2'}) + p3 = self.product_model.create({'name': 'Test P3'}) + sub_p3 = self.product_model.create({'name': 'Test sub P3'}) + + # P1 BoM: Need one P2 and one P3 + # P2 has a phantom BoM which need one sub P2 + # P3 has a normal Bom which need one sub p3 + p1_bom = self.create_bom(p1, components=[p2, p3]) + self.create_bom(p2, phantom=True, components=[sub_p2]) + self.create_bom(p3, components=[sub_p3]) + + result = p1_bom.create_dismantling_bom() + + dmtl_bom = self.check_result_and_load_entity('mrp.bom', result) + self.assertEqual(sub_p2.id, dmtl_bom.product_id.id) + self.assertEqual(sub_p2.product_tmpl_id.id, + dmtl_bom.product_tmpl_id.id) + self.assertEqual(True, dmtl_bom.dismantling) + self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id) + + # Consume p1self.assertEqual(1, len(dmtl_bom.bom_line_ids)) + self.assertEqual(p1.id, dmtl_bom.bom_line_ids[0].product_id.id) + + # Sub P3 in by-products + self.assertEqual(1, len(dmtl_bom.sub_products)) + self.assertEqual(p3.id, dmtl_bom.sub_products[0].product_id.id) + + def test_multi_unit_components(self): + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P2'}) + p3 = self.product_model.create({'name': 'Test P3'}) + p4 = self.product_model.create({'name': 'Test P4'}) + + # P1 BoM (produced 1 dozen): Needs 2 P2, 4 P3 and 2 dozen P4 + # P2 has a phantom BoM which need one Dozen of P3 + # + # => Dismantling BoM: + # Product: P3 (produced 28 unit) + # Component: 1 dozen P1 + + p1_bom = self.create_bom(p1, qty=1, uom=self.dozen_uom) + self.create_bom_line(p1_bom, p2, qty=2) + self.create_bom_line(p1_bom, p3, qty=4) + self.create_bom_line(p1_bom, p4, qty=2, uom=self.dozen_uom) + + p2_bom = self.create_bom(p2, phantom=True) + self.create_bom_line(p2_bom, p3, qty=1, uom=self.dozen_uom) + + result = p1_bom.create_dismantling_bom() + + dmtl_bom = self.check_result_and_load_entity('mrp.bom', result) + self.assertEqual(p3.id, dmtl_bom.product_id.id) + self.assertEqual(28, dmtl_bom.product_qty) + self.assertEqual(self.unit_uom, dmtl_bom.product_uom) + self.assertEqual(True, dmtl_bom.dismantling) + self.assertEqual(p1.id, dmtl_bom.dismantled_product_id.id) + + # Consume 1 dozen p1 + self.assertEqual(1, len(dmtl_bom.bom_line_ids)) + + dmtl_bom_line = dmtl_bom.bom_line_ids[0] + self.assertEqual(p1.id, dmtl_bom_line.product_id.id) + self.assertEqual(1, dmtl_bom_line.product_qty) + self.assertEqual(self.dozen_uom, dmtl_bom_line.product_uom) + + # Byproducts + self.assertEqual(1, len(dmtl_bom.sub_products)) + + dmtl_sub_product = dmtl_bom.sub_products[0] + self.assertEqual(p4.id, dmtl_sub_product.product_id.id) + self.assertEqual(24, dmtl_sub_product.product_qty) + self.assertEqual(self.unit_uom, dmtl_sub_product.product_uom) + + def test_create_mrp_production(self): + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P2'}) + bom = self.create_bom(p1, qty=2, uom=self.dozen_uom, components=[p2]) + + self.assertEqual( + 0, + self.mrp_production_model.search_count([('bom_id', '=', bom.id)]) + ) + + result = bom.create_mrp_production() + mrp_prod = self.check_result_and_load_entity('mrp.production', result) + + self.assertEqual(bom, mrp_prod.bom_id) + self.assertEqual(p1, mrp_prod.product_id) + self.assertEqual(2, mrp_prod.product_qty) + self.assertEqual(2, mrp_prod.product_qty) + self.assertEqual(self.dozen_uom, mrp_prod.product_uom) diff --git a/mrp_bom_dismantling/tests/test_product.py b/mrp_bom_dismantling/tests/test_product.py new file mode 100644 index 000000000..97776d141 --- /dev/null +++ b/mrp_bom_dismantling/tests/test_product.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp.tests import TransactionCase + + +class TestProduct(TransactionCase): + + def test_action_view_bom(self): + # Covering test + p1 = self.env['product.product'].create({'name': 'Test P1'}) + result = p1.action_view_bom() + self.assertIn(('dismantling', '=', False), result['domain']) diff --git a/mrp_bom_dismantling/tests/test_product_produce.py b/mrp_bom_dismantling/tests/test_product_produce.py new file mode 100644 index 000000000..9680c2143 --- /dev/null +++ b/mrp_bom_dismantling/tests/test_product_produce.py @@ -0,0 +1,96 @@ +# -*- coding: utf8 -*- + +from openerp.tests import TransactionCase + + +class TestProductProduce(TransactionCase): + + def test_produced_products_lots(self): + produce_model = self.env['mrp.product.produce'] + product_model = self.env['product.product'] + lot_model = self.env['stock.production.lot'] + quant_model = self.env['stock.quant'] + + unit_uom = self.browse_ref('product.product_uom_unit') + + wh_main = self.browse_ref('stock.warehouse0') + + # Create simple bom with by products + p1 = product_model.create({'name': 'Test P1'}) + p2 = product_model.create({'name': 'Test P2'}) + p3 = product_model.create({'name': 'Test P3'}) + + # We have 1 P2 in stock + inventory = self.env['stock.inventory'].create({ + 'name': 'P2 inventory', + 'location_id': wh_main.lot_stock_id.id, + 'filter': 'partial' + }) + inventory.prepare_inventory() + + self.env['stock.inventory.line'].create({ + 'inventory_id': inventory.id, + 'product_id': p2.id, + 'location_id': wh_main.lot_stock_id.id, + 'product_qty': 1 + }) + inventory.action_done() + + # P1 need P2 and generates one byproduct P3 + bom = self.env['mrp.bom'].create({ + 'product_tmpl_id': p1.product_tmpl_id.id, + 'product_id': p1.id, + 'product_qty': 1, + 'product_uom': unit_uom.id, + }) + + self.env['mrp.bom.line'].create({ + 'bom_id': bom.id, + 'product_id': p2.id, + 'product_qty': 1, + 'product_uom': unit_uom.id, + }) + + self.env['mrp.subproduct'].create({ + 'bom_id': bom.id, + 'product_id': p3.id, + 'product_qty': 1, + 'product_uom': unit_uom.id, + }) + + # Create MRP Order + mrp_order_id = bom.create_mrp_production()['res_id'] + mrp_order = self.env['mrp.production'].browse(mrp_order_id) + mrp_order.action_confirm() + mrp_order.action_assign() + + # Wizard simulation + wizard = produce_model.with_context(active_id=mrp_order_id).create({}) + wizard.on_change_product_id() + self.assertEqual(2, len(wizard.move_lot_ids)) + self.assertEqual([p1, p3], [x.product_id for x in wizard.move_lot_ids]) + + lot_p1 = lot_model.create({'name': 'LOT_01', 'product_id': p1.id}) + wizard.move_lot_ids[0].lot_id = lot_p1 + + lot_p3 = lot_model.create({'name': 'LOT_03', 'product_id': p3.id}) + wizard.move_lot_ids[1].lot_id = lot_p3 + + wizard.do_produce() + + # Check created move in mrp.production + mrp_order.refresh() + self.assertEqual(lot_p1, + mrp_order.move_created_ids2[0].restrict_lot_id) + self.assertEqual(lot_p3, + mrp_order.move_created_ids2[1].restrict_lot_id) + + # Check stock.quants + p1_quants = quant_model.search([('product_id', '=', p1.id)]) + self.assertEqual(1, len(p1_quants)) + self.assertEqual(lot_p1, p1_quants.lot_id) + self.assertEqual(1, p1_quants.qty) + + p3_quants = quant_model.search([('product_id', '=', p3.id)]) + self.assertEqual(1, len(p3_quants)) + self.assertEqual(1, p3_quants.qty) diff --git a/mrp_bom_dismantling/tests/test_template.py b/mrp_bom_dismantling/tests/test_template.py new file mode 100644 index 000000000..43e3b6192 --- /dev/null +++ b/mrp_bom_dismantling/tests/test_template.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp.tests import TransactionCase + + +class TestTemplate(TransactionCase): + + def test_bom_count(self): + tmpl_model = self.env['product.template'] + + tmpl1 = tmpl_model.create({'name': 'Template 1'}) + self.assertEqual(0, tmpl1.bom_count) + + # Create a BoM for this template + bom_model = self.env['mrp.bom'] + bom_model.create({'product_tmpl_id': tmpl1.id}) + + self.assertEqual(1, tmpl1.bom_count) + + # Create a dismantling BoM + other_product = self.env['product.product'].create({ + 'name': 'Other product' + }) + + bom_model.create({ + 'product_tmpl_id': tmpl1.id, + 'dismantling': True, + 'dismantled_product_id': other_product.id + }) + + self.assertEqual(1, tmpl1.bom_count) + + # Check count on another template + tmpl2 = tmpl_model.create({'name': 'Template 2'}) + self.assertEqual(0, tmpl2 + + .bom_count) + + # And on dismantled product + self.assertEqual(0, other_product.product_tmpl_id.bom_count) diff --git a/mrp_bom_dismantling/views/mrp_bom.xml b/mrp_bom_dismantling/views/mrp_bom.xml new file mode 100644 index 000000000..8e8ceead2 --- /dev/null +++ b/mrp_bom_dismantling/views/mrp_bom.xml @@ -0,0 +1,70 @@ + + + + + + Dismantling + ir.actions.act_window + mrp.bom + [('dismantling', '=', True)] + {'default_dismantling': True} + form + + + + + [('dismantling', '=', False)] + + + + + mrp_bom_form + mrp.bom + + + +
+ +
+
+ + + + + +
+ + + + mrp_bom_tree + mrp.bom + + + + + + + + + + + mrp.bom.select + mrp.bom + + + + + + + +
+
\ No newline at end of file diff --git a/mrp_bom_dismantling/views/product_template.xml b/mrp_bom_dismantling/views/product_template.xml new file mode 100644 index 000000000..22532a775 --- /dev/null +++ b/mrp_bom_dismantling/views/product_template.xml @@ -0,0 +1,9 @@ + + + + + + [('dismantling', '=', False)] + + + \ No newline at end of file diff --git a/mrp_bom_dismantling/wizards/__init__.py b/mrp_bom_dismantling/wizards/__init__.py new file mode 100644 index 000000000..4a94536b7 --- /dev/null +++ b/mrp_bom_dismantling/wizards/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import mrp_product_produce diff --git a/mrp_bom_dismantling/wizards/mrp_product_produce.py b/mrp_bom_dismantling/wizards/mrp_product_produce.py new file mode 100644 index 000000000..2995a6bb7 --- /dev/null +++ b/mrp_bom_dismantling/wizards/mrp_product_produce.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# © 2016 Cyril Gaudin (Camptocamp) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp import api, fields, models + + +class MrpByProductLine(models.TransientModel): + _name = "mrp.product.produced.line" + + produce_id = fields.Many2one('mrp.product.produce', required=True, + string="Produce") + move_id = fields.Many2one('stock.move', required=True) + product_id = fields.Many2one('product.product', + related='move_id.product_id') + lot_id = fields.Many2one('stock.production.lot', string='Lot') + + +class MrpProductProduce(models.TransientModel): + _inherit = "mrp.product.produce" + move_lot_ids = fields.One2many( + 'mrp.product.produced.line', + inverse_name='produce_id', + ) + + @api.onchange("product_id") + def on_change_product_id(self): + """ Listen to product_id changes just for filling byproducts_lot_ids. + """ + if not self.move_lot_ids: + mrp_prod = self.env["mrp.production"].browse( + self.env.context['active_id'] + ) + + self.move_lot_ids = [ + (0, None, {'move_id': move}) + for move in mrp_prod.move_created_ids + ] + + @api.multi + def do_produce(self): + """ Stock produced products lot_id and call parent do_produce + """ + mapping_move_lot = {} + for move_lot in self.move_lot_ids: + if move_lot.lot_id: + mapping_move_lot[move_lot.move_id.id] = move_lot.lot_id.id + + super(MrpProductProduce, self.with_context( + mapping_move_lot=mapping_move_lot + )).do_produce() diff --git a/mrp_bom_dismantling/wizards/mrp_product_produce.xml b/mrp_bom_dismantling/wizards/mrp_product_produce.xml new file mode 100644 index 000000000..2bea3adc7 --- /dev/null +++ b/mrp_bom_dismantling/wizards/mrp_product_produce.xml @@ -0,0 +1,30 @@ + + + + + MRP Product Produce + mrp.product.produce + + + + + + True + + + + + + + + + + + + + + + + + diff --git a/oca_dependencies.txt b/oca_dependencies.txt index 0f49141ed..e9e2f94a5 100644 --- a/oca_dependencies.txt +++ b/oca_dependencies.txt @@ -1,3 +1,4 @@ # List the OCA project dependencies, one per line # Add a repository url and branch if you need a forked version -product-attribute \ No newline at end of file +product-attribute +stock-logistics-warehouse