diff --git a/mrp_production_grouped_by_product/README.rst b/mrp_production_grouped_by_product/README.rst new file mode 100644 index 000000000..3f6262529 --- /dev/null +++ b/mrp_production_grouped_by_product/README.rst @@ -0,0 +1 @@ +**This file is going to be generated by oca-gen-addon-readme.** diff --git a/mrp_production_grouped_by_product/__init__.py b/mrp_production_grouped_by_product/__init__.py new file mode 100644 index 000000000..a9e337226 --- /dev/null +++ b/mrp_production_grouped_by_product/__init__.py @@ -0,0 +1,2 @@ + +from . import models diff --git a/mrp_production_grouped_by_product/__manifest__.py b/mrp_production_grouped_by_product/__manifest__.py new file mode 100644 index 000000000..d1ecdd69a --- /dev/null +++ b/mrp_production_grouped_by_product/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2018 Tecnativa - David Vidal +# Copyright 2018 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Production Grouped By Product', + 'version': '11.0.1.0.0', + 'category': 'MRP', + 'author': 'Tecnativa, ' + 'Odoo Community Association (OCA)', + 'website': 'https://github.com/oca/manufacture', + 'license': 'AGPL-3', + 'depends': [ + 'mrp', + ], + 'data': [ + 'views/stock_picking_type_views.xml', + ], + 'installable': True, +} diff --git a/mrp_production_grouped_by_product/i18n/es.po b/mrp_production_grouped_by_product/i18n/es.po new file mode 100644 index 000000000..e9c4e2b95 --- /dev/null +++ b/mrp_production_grouped_by_product/i18n/es.po @@ -0,0 +1,63 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mrp_production_grouped_by_product +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 11.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-06-03 23:16+0000\n" +"PO-Revision-Date: 2018-06-03 23:16+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_production_grouped_by_product +#: model:ir.model.fields,field_description:mrp_production_grouped_by_product.field_stock_picking_type_mo_grouping_interval +msgid "MO grouping interval (days)" +msgstr "Intervalo de agrupación de OFs (días)" + +#. module: mrp_production_grouped_by_product +#: model:ir.model.fields,field_description:mrp_production_grouped_by_product.field_stock_picking_type_mo_grouping_max_hour +msgid "MO grouping max. hour (UTC)" +msgstr "Hora máx. agrupación OFs (UTC)" + +#. module: mrp_production_grouped_by_product +#: model:ir.model,name:mrp_production_grouped_by_product.model_mrp_production +msgid "Manufacturing Order" +msgstr "Orden de fabricación" + +#. module: mrp_production_grouped_by_product +#: model:ir.model,name:mrp_production_grouped_by_product.model_procurement_rule +msgid "Procurement Rule" +msgstr "Regla de abastecimiento" + +#. module: mrp_production_grouped_by_product +#: model:ir.model.fields,help:mrp_production_grouped_by_product.field_stock_picking_type_mo_grouping_max_hour +msgid "The maximum hour (between 0 and 23) for considering new manufacturing orders inside the same interval period, and thus being grouped on the same MO. IMPORTANT: The hour should be expressed in UTC." +msgstr "La hora máxima (entre 0 y 23) para considerar nuevas órdenes de fabricación dentro del mismo periodo de tiempo, y por tanto siendo agrupadas dentro de la misma OF. IMPORTANTE: La hora debe expresarse en UTC." + +#. module: mrp_production_grouped_by_product +#: model:ir.model.fields,help:mrp_production_grouped_by_product.field_stock_picking_type_mo_grouping_interval +msgid "The number of days for grouping together on the same manufacturing order." +msgstr "El número de días para agrupar juntas las órdenes de fabricación." + +#. module: mrp_production_grouped_by_product +#: model:ir.model,name:mrp_production_grouped_by_product.model_stock_picking_type +msgid "The operation type determines the picking view" +msgstr "El tipo de operación determina la vista de la operación" + +#. module: mrp_production_grouped_by_product +#: code:addons/mrp_production_grouped_by_product/models/stock_picking_type.py:36 +#, python-format +msgid "You have to enter a positive value for interval." +msgstr "Debe introducir un valor positivo para el intervalo." + +#. module: mrp_production_grouped_by_product +#: code:addons/mrp_production_grouped_by_product/models/stock_picking_type.py:29 +#, python-format +msgid "You have to enter a valid hour between 0 and 23." +msgstr "Debe introducir una hora válida entre 0 y 23." diff --git a/mrp_production_grouped_by_product/models/__init__.py b/mrp_production_grouped_by_product/models/__init__.py new file mode 100644 index 000000000..b4b7e408a --- /dev/null +++ b/mrp_production_grouped_by_product/models/__init__.py @@ -0,0 +1,4 @@ + +from . import mrp_production +from . import procurement +from . import stock_picking_type diff --git a/mrp_production_grouped_by_product/models/mrp_production.py b/mrp_production_grouped_by_product/models/mrp_production.py new file mode 100644 index 000000000..5dd54a23c --- /dev/null +++ b/mrp_production_grouped_by_product/models/mrp_production.py @@ -0,0 +1,91 @@ +# Copyright 2018 Tecnativa - David Vidal +# Copyright 2018 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models +from odoo.tools import config + + +class MrpProduction(models.Model): + _inherit = 'mrp.production' + + def _post_mo_merging_adjustments(self, vals): + """Called when a new MO is merged onto existing one for adjusting the + needed values according this merging. + + :param self: Single record of the target record where merging. + :param vals: Dictionary with the new record values. + """ + self.ensure_one() + new_vals = { + 'origin': (self.origin or '') + ",%s" % vals['origin'], + } + if vals.get('move_dest_ids'): + new_vals['move_dest_ids'] = vals['move_dest_ids'] + self.move_finished_ids.move_dest_ids = vals['move_dest_ids'] + self.write(new_vals) + + def _get_grouping_target_domain(self, vals): + """Get the domain for searching manufacturing orders that can match + with the criteria we want to use. + + :param vals: Values dictionary of the MO to be created. + + :return: Odoo domain. + """ + domain = [ + ('product_id', '=', vals['product_id']), + ('picking_type_id', '=', vals['picking_type_id']), + ('bom_id', '=', vals.get('bom_id', False)), + ('routing_id', '=', vals.get('routing_id', False)), + ('company_id', '=', vals.get('company_id', False)), + ('state', '=', 'confirmed'), + ] + if not vals.get('date_planned_finished'): + return domain + date = fields.Datetime.from_string(vals['date_planned_finished']) + pt = self.env['stock.picking.type'].browse(vals['picking_type_id']) + if date.hour < pt.mo_grouping_max_hour: + date_end = date.replace( + hour=pt.mo_grouping_max_hour, minute=0, second=0, + ) + else: + date_end = date.replace( + day=date.day + 1, hour=pt.mo_grouping_max_hour, minute=0, + second=0, + ) + date_start = date_end - relativedelta(days=pt.mo_grouping_interval) + domain += [ + ('date_planned_finished', '>', + fields.Datetime.to_string(date_start)), + ('date_planned_finished', '<=', + fields.Datetime.to_string(date_end)), + ] + return domain + + def _find_grouping_target(self, vals): + """Return the matching order for grouping. + + :param vals: Values dictionary of the MO to be created. + + :return: Target manufacturing order record (or empty record). + """ + return self.env['mrp.production'].search( + self._get_grouping_target_domain(vals), limit=1, + ) + + @api.model + def create(self, vals): + context = self.env.context + if (context.get('group_mo_by_product') and + (not config['test_enable'] or context.get('test_group_mo'))): + mo = self._find_grouping_target(vals) + if mo: + self.env['change.production.qty'].create({ + 'mo_id': mo.id, + 'product_qty': mo.product_qty + vals['product_qty'], + }).change_prod_qty() + mo._post_mo_merging_adjustments(vals) + return mo + return super(MrpProduction, self).create(vals) diff --git a/mrp_production_grouped_by_product/models/procurement.py b/mrp_production_grouped_by_product/models/procurement.py new file mode 100644 index 000000000..19368adc0 --- /dev/null +++ b/mrp_production_grouped_by_product/models/procurement.py @@ -0,0 +1,18 @@ +# Copyright 2018 Tecnativa - David Vidal +# Copyright 2018 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProcurementRule(models.Model): + _inherit = 'procurement.rule' + + def _run_manufacture(self, product_id, product_qty, product_uom, + location_id, name, origin, values): + return super( + ProcurementRule, self.with_context(group_mo_by_product=True), + )._run_manufacture( + product_id, product_qty, product_uom, location_id, name, origin, + values, + ) diff --git a/mrp_production_grouped_by_product/models/stock_picking_type.py b/mrp_production_grouped_by_product/models/stock_picking_type.py new file mode 100644 index 000000000..f5b9c3bd5 --- /dev/null +++ b/mrp_production_grouped_by_product/models/stock_picking_type.py @@ -0,0 +1,37 @@ +# Copyright 2018 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, exceptions, fields, models + + +class StockPickingType(models.Model): + _inherit = 'stock.picking.type' + + mo_grouping_max_hour = fields.Integer( + string="MO grouping max. hour (UTC)", + help="The maximum hour (between 0 and 23) for considering new " + "manufacturing orders inside the same interval period, and thus " + "being grouped on the same MO. IMPORTANT: The hour should be " + "expressed in UTC.", + default=19, + ) + mo_grouping_interval = fields.Integer( + string="MO grouping interval (days)", + help="The number of days for grouping together on the same " + "manufacturing order.", + default=1, + ) + + @api.constrains('mo_grouping_max_hour') + def _check_mo_grouping_max_hour(self): + if self.mo_grouping_max_hour < 0 or self.mo_grouping_max_hour > 23: + raise exceptions.ValidationError( + _("You have to enter a valid hour between 0 and 23."), + ) + + @api.constrains('mo_grouping_interval') + def _check_mo_grouping_interval(self): + if self.mo_grouping_interval < 0: + raise exceptions.ValidationError( + _("You have to enter a positive value for interval."), + ) diff --git a/mrp_production_grouped_by_product/readme/CONFIGURE.rst b/mrp_production_grouped_by_product/readme/CONFIGURE.rst new file mode 100644 index 000000000..163bf6c3b --- /dev/null +++ b/mrp_production_grouped_by_product/readme/CONFIGURE.rst @@ -0,0 +1,17 @@ +To configure the time frame for grouping manufacturing order: + +#. Go to *Inventory > Configuration > Warehouse Management > Operation Types* +#. Locate the manufacturing type you are using (default one is called + "Manufacturing"). +#. Open it and change these 2 values: + + * MO grouping max. hour (UTC): The maximum hour (between 0 and 23) for + considering new manufacturing orders inside the same interval period, and + thus being grouped on the same MO. IMPORTANT: The hour should be expressed + in UTC. + * MO grouping interval (days): The number of days for grouping together on + the same manufacturing order. + + Example: If you leave the default values 19 and 1, all the planned orders + between 19:00:01 of the previous day and 20:00:00 of the target date will + be grouped together. diff --git a/mrp_production_grouped_by_product/readme/CONTRIBUTORS.rst b/mrp_production_grouped_by_product/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..cfa3cdf10 --- /dev/null +++ b/mrp_production_grouped_by_product/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* Tecnativa __ + + * David Vidal + * Pedro M. Baeza diff --git a/mrp_production_grouped_by_product/readme/DESCRIPTION.rst b/mrp_production_grouped_by_product/readme/DESCRIPTION.rst new file mode 100644 index 000000000..50962a392 --- /dev/null +++ b/mrp_production_grouped_by_product/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +When you have several sales orders with make to order (MTO) products that +require to be manufactured, you end up with one manufacturing order for each of +these sales orders, which is very bad for the management. + +With this module, each time an MTO manufacturing order is required to be +created, it first checks that there's no other existing order not yet started +for the same product and bill of materials inside the specied time frame , and +if there's one, then the quantity of that order is increased instead of +creating a new one. diff --git a/mrp_production_grouped_by_product/readme/ROADMAP.rst b/mrp_production_grouped_by_product/readme/ROADMAP.rst new file mode 100644 index 000000000..7845179c4 --- /dev/null +++ b/mrp_production_grouped_by_product/readme/ROADMAP.rst @@ -0,0 +1 @@ +* Add a check in the product form for excluding it from being grouped. diff --git a/mrp_production_grouped_by_product/tests/__init__.py b/mrp_production_grouped_by_product/tests/__init__.py new file mode 100644 index 000000000..66f012923 --- /dev/null +++ b/mrp_production_grouped_by_product/tests/__init__.py @@ -0,0 +1,2 @@ + +from . import test_mrp_production_grouped_by_product diff --git a/mrp_production_grouped_by_product/tests/test_mrp_production_grouped_by_product.py b/mrp_production_grouped_by_product/tests/test_mrp_production_grouped_by_product.py new file mode 100644 index 000000000..28afaa0f8 --- /dev/null +++ b/mrp_production_grouped_by_product/tests/test_mrp_production_grouped_by_product.py @@ -0,0 +1,102 @@ +# Copyright 2018 Tecnativa - David Vidal +# Copyright 2018 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import exceptions +from odoo.tests import common + + +class TestProductionGroupedByProduct(common.SavepointCase): + at_install = False + post_install = True + + @classmethod + def setUpClass(cls): + super(TestProductionGroupedByProduct, cls).setUpClass() + cls.ProcurementGroup = cls.env['procurement.group'] + cls.MrpProduction = cls.env['mrp.production'] + cls.env.user.company_id.manufacturing_lead = 0 + cls.env.user.tz = False # Make sure there's no timezone in user + cls.picking_type = cls.env.ref('mrp.picking_type_manufacturing') + cls.product1 = cls.env['product.product'].create({ + 'name': 'TEST Muffin', + 'route_ids': [(6, 0, [ + cls.env.ref('mrp.route_warehouse0_manufacture').id])], + 'type': 'product', + 'produce_delay': 0, + }) + cls.product2 = cls.env['product.product'].create({ + 'name': 'TEST Paper muffin cup', + 'type': 'product', + }) + cls.product3 = cls.env['product.product'].create({ + 'name': 'TEST Muffin paset', + 'type': 'product', + }) + cls.bom = cls.env['mrp.bom'].create({ + 'product_id': cls.product1.id, + 'product_tmpl_id': cls.product1.product_tmpl_id.id, + 'type': 'normal', + 'bom_line_ids': [(0, 0, { + 'product_id': cls.product2.id, + 'product_qty': 1, + }), (0, 0, { + 'product_id': cls.product3.id, + 'product_qty': 0.2, + })] + }) + cls.stock_picking_type = cls.env.ref('stock.picking_type_out') + cls.mo = cls.MrpProduction.create({ + 'bom_id': cls.bom.id, + 'product_id': cls.product1.id, + 'product_qty': 2, + 'product_uom_id': cls.product1.uom_id.id, + 'date_planned_finished': '2018-06-01 15:00:00', + 'date_planned_start': '2018-06-01 15:00:00', + }) + cls.warehouse = cls.env['stock.warehouse'].search([ + ('company_id', '=', cls.env.user.company_id.id), + ], limit=1) + # Add an MTO move + cls.move = cls.env['stock.move'].create({ + 'name': cls.product1.name, + 'product_id': cls.product1.id, + 'product_uom_qty': 10, + 'product_uom': cls.product1.uom_id.id, + 'location_id': cls.warehouse.lot_stock_id.id, + 'location_dest_id': ( + cls.env.ref('stock.stock_location_customers').id + ), + 'procure_method': 'make_to_order', + 'warehouse_id': cls.warehouse.id, + 'date': '2018-06-01 18:00:00', + }) + + def test_mo_by_product(self): + self.move.with_context(test_group_mo=True)._action_confirm(merge=False) + self.ProcurementGroup.with_context(test_group_mo=True).run_scheduler() + mo = self.MrpProduction.search([('product_id', '=', self.product1.id)]) + self.assertEqual(len(mo), 1) + self.assertEqual(mo.product_qty, 12) + # Run again the scheduler to see if quantities are altered + self.ProcurementGroup.with_context(test_group_mo=True).run_scheduler() + mo = self.MrpProduction.search([('product_id', '=', self.product1.id)]) + self.assertEqual(len(mo), 1) + self.assertEqual(mo.product_qty, 12) + + def test_mo_other_date(self): + self.move.date = '2018-06-01 20:01:00' + self.move.with_context(test_group_mo=True)._action_confirm(merge=False) + self.ProcurementGroup.with_context(test_group_mo=True).run_scheduler() + mo = self.MrpProduction.search([('product_id', '=', self.product1.id)]) + self.assertEqual(len(mo), 2) + + def test_check_mo_grouping_max_hour(self): + with self.assertRaises(exceptions.ValidationError): + self.picking_type.mo_grouping_max_hour = 25 + with self.assertRaises(exceptions.ValidationError): + self.picking_type.mo_grouping_max_hour = -1 + + def test_check_mo_grouping_interval(self): + with self.assertRaises(exceptions.ValidationError): + self.picking_type.mo_grouping_interval = -1 diff --git a/mrp_production_grouped_by_product/views/stock_picking_type_views.xml b/mrp_production_grouped_by_product/views/stock_picking_type_views.xml new file mode 100644 index 000000000..2b24b05e5 --- /dev/null +++ b/mrp_production_grouped_by_product/views/stock_picking_type_views.xml @@ -0,0 +1,17 @@ + + + + + + stock.picking.type + + + + + + + + + +