diff --git a/mrp_multi_level/README.rst b/mrp_multi_level/README.rst new file mode 100644 index 000000000..2ff008382 --- /dev/null +++ b/mrp_multi_level/README.rst @@ -0,0 +1,76 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +=============== +Multi Level MRP +=============== + +This module allows you to calculate, based in known inventory, demand, and +supply, and based on parameters set at product variant level, the new +procurements for each product. + +To do this, the calculation starts at top level of the bill of material +and explodes this down to the lowest level. + +Key Features +------------ +* MRP parameters at product variant level +* Basic forecast system +* Cron job to calculate the MRP demand +* Manually calculate the MRP demand +* Confirm the calculated MRP demand and create BID's, PO's, or MO's +* View to see the products for which action is needed + + +Installation +============ + + +Usage +===== + + +.. 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/11.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 smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Original Odoo icons. + + +Contributors +------------ + +* Wim Audenaert +* Jordi Ballester +* Lois Rilo + + +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_multi_level/__init__.py b/mrp_multi_level/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/mrp_multi_level/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/mrp_multi_level/__manifest__.py b/mrp_multi_level/__manifest__.py new file mode 100644 index 000000000..7504f1284 --- /dev/null +++ b/mrp_multi_level/__manifest__.py @@ -0,0 +1,43 @@ +# Copyright 2016 Ucamco - Wim Audenaert +# Copyright 2016-18 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + 'name': 'MRP Multi Level', + 'version': '11.0.1.0.0', + 'author': 'Ucamco, ' + 'Eficent, ' + 'Odoo Community Association (OCA)', + 'summary': 'Adds an MRP Scheduler', + 'website': 'https://github.com/OCA/manufacture', + 'category': 'Manufacturing', + 'depends': [ + 'mrp', + 'stock', + 'purchase', + 'stock_demand_estimate', + ], + 'data': [ + 'security/mrp_multi_level_security.xml', + 'security/ir.model.access.csv', + 'views/mrp_area_view.xml', + 'views/product_view.xml', + 'views/stock_location_view.xml', + 'views/mrp_product_view.xml', + 'wizards/mrp_inventory_procure_view.xml', + 'views/mrp_inventory_view.xml', + 'wizards/mrp_multi_level_view.xml', + 'views/mrp_menuitem.xml', + 'data/mrp_multi_level_cron.xml', + 'data/mrp_area_data.xml', + ], + 'demo': [ + 'demo/product_category_demo.xml', + 'demo/product_product_demo.xml', + 'demo/res_partner_demo.xml', + 'demo/product_supplierinfo_demo.xml', + 'demo/mrp_bom_demo.xml', + 'demo/initial_on_hand_demo.xml', + ], + 'installable': True, + 'application': True, +} diff --git a/mrp_multi_level/data/mrp_area_data.xml b/mrp_multi_level/data/mrp_area_data.xml new file mode 100644 index 000000000..6ce02c128 --- /dev/null +++ b/mrp_multi_level/data/mrp_area_data.xml @@ -0,0 +1,8 @@ + + + + WH/Stock + + + + diff --git a/mrp_multi_level/data/mrp_multi_level_cron.xml b/mrp_multi_level/data/mrp_multi_level_cron.xml new file mode 100755 index 000000000..83c2417ca --- /dev/null +++ b/mrp_multi_level/data/mrp_multi_level_cron.xml @@ -0,0 +1,17 @@ + + + + + Multi Level MRP + + + 1 + days + -1 + + + + + + + diff --git a/mrp_multi_level/demo/initial_on_hand_demo.xml b/mrp_multi_level/demo/initial_on_hand_demo.xml new file mode 100644 index 000000000..a2cd4991c --- /dev/null +++ b/mrp_multi_level/demo/initial_on_hand_demo.xml @@ -0,0 +1,32 @@ + + + + + Simulating MRP + + + + + + 10 + + + + + + + 20 + + + + + + + 15 + + + + + + + \ No newline at end of file diff --git a/mrp_multi_level/demo/mrp_bom_demo.xml b/mrp_multi_level/demo/mrp_bom_demo.xml new file mode 100644 index 000000000..1b1e9f7b5 --- /dev/null +++ b/mrp_multi_level/demo/mrp_bom_demo.xml @@ -0,0 +1,81 @@ + + + + + + + 5 + + + + 2 + + 5 + + + + + 3 + + 5 + + + + + + + + 5 + + + + 2 + + 5 + + + + + 3 + + 5 + + + + + + + 5 + + + + 3 + + 5 + + + + + 2 + + 5 + + + + + + + 5 + + + + 3 + + 5 + + + diff --git a/mrp_multi_level/demo/product_category_demo.xml b/mrp_multi_level/demo/product_category_demo.xml new file mode 100644 index 000000000..bf989ea45 --- /dev/null +++ b/mrp_multi_level/demo/product_category_demo.xml @@ -0,0 +1,6 @@ + + + + MRP + + diff --git a/mrp_multi_level/demo/product_product_demo.xml b/mrp_multi_level/demo/product_product_demo.xml new file mode 100644 index 000000000..358d70978 --- /dev/null +++ b/mrp_multi_level/demo/product_product_demo.xml @@ -0,0 +1,59 @@ + + + + FP-1 + + product + + + 2 + + + + + FP-2 + + product + + + 1 + + + + + SF-1 + + product + + + 1 + + + + SF-2 + + product + + + 3 + + + + + PP-1 + + product + + + + + + + PP-2 + + product + + + + + diff --git a/mrp_multi_level/demo/product_supplierinfo_demo.xml b/mrp_multi_level/demo/product_supplierinfo_demo.xml new file mode 100644 index 000000000..ab223f04c --- /dev/null +++ b/mrp_multi_level/demo/product_supplierinfo_demo.xml @@ -0,0 +1,18 @@ + + + + + + 4 + 0 + 100 + + + + + + 2 + 0 + 100 + + diff --git a/mrp_multi_level/demo/res_partner_demo.xml b/mrp_multi_level/demo/res_partner_demo.xml new file mode 100644 index 000000000..ecb86d323 --- /dev/null +++ b/mrp_multi_level/demo/res_partner_demo.xml @@ -0,0 +1,10 @@ + + + + Lazer Tech + 1 + + + + + diff --git a/mrp_multi_level/models/__init__.py b/mrp_multi_level/models/__init__.py new file mode 100644 index 000000000..353d6e3ed --- /dev/null +++ b/mrp_multi_level/models/__init__.py @@ -0,0 +1,6 @@ +from . import mrp_area +from . import stock_location +from . import product +from . import mrp_product +from . import mrp_move +from . import mrp_inventory diff --git a/mrp_multi_level/models/mrp_area.py b/mrp_multi_level/models/mrp_area.py new file mode 100644 index 000000000..2111dcd4a --- /dev/null +++ b/mrp_multi_level/models/mrp_area.py @@ -0,0 +1,19 @@ +# © 2016 Ucamco - Wim Audenaert +# © 2016 Eficent Business and IT Consulting Services S.L. +# - Jordi Ballester Alomar +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class MrpArea(models.Model): + _name = 'mrp.area' + + name = fields.Char('Name') + warehouse_id = fields.Many2one( + comodel_name='stock.warehouse', string='Warehouse', + required=True) + location_id = fields.Many2one( + comodel_name='stock.location', string='Location', + required=True) + active = fields.Boolean(default=True) diff --git a/mrp_multi_level/models/mrp_inventory.py b/mrp_multi_level/models/mrp_inventory.py new file mode 100644 index 000000000..d0bf1be91 --- /dev/null +++ b/mrp_multi_level/models/mrp_inventory.py @@ -0,0 +1,44 @@ +# © 2016 Ucamco - Wim Audenaert +# Copyright 2016-18 Eficent Business and IT Consulting Services S.L. +# - Jordi Ballester Alomar +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class MrpInventory(models.Model): + _name = 'mrp.inventory' + _order = 'mrp_product_id, date' + _description = 'MRP inventory projections' + _rec_name = 'mrp_product_id' + + # TODO: uom?? + # TODO: name to pass to procurements? + # TODO: compute procurement_date to pass to the wizard? not needed for PO at least. Check for MO and moves + # TODO: substract qty already procured. + # TODO: show a LT based on the procure method? + # TODO: add to_procure_date + + mrp_area_id = fields.Many2one( + comodel_name='mrp.area', string='MRP Area', + related='mrp_product_id.mrp_area_id', + ) + mrp_product_id = fields.Many2one( + comodel_name='mrp.product', string='Product', + index=True, + ) + uom_id = fields.Many2one( + comodel_name='product.uom', string='Product UoM', + compute='_compute_uom_id', + ) + date = fields.Date(string='Date') + demand_qty = fields.Float(string='Demand') + supply_qty = fields.Float(string='Supply') + initial_on_hand_qty = fields.Float(string='Starting Inventory') + final_on_hand_qty = fields.Float(string='Forecasted Inventory') + to_procure = fields.Float(string='To procure') + + @api.multi + def _compute_uom_id(self): + for rec in self: + rec.uom_id = rec.mrp_product_id.product_id.uom_id diff --git a/mrp_multi_level/models/mrp_move.py b/mrp_multi_level/models/mrp_move.py new file mode 100644 index 000000000..126d08741 --- /dev/null +++ b/mrp_multi_level/models/mrp_move.py @@ -0,0 +1,173 @@ +# © 2016 Ucamco - Wim Audenaert +# © 2016-18 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import models, fields, api, _ +from odoo import exceptions + + +class MrpMove(models.Model): + _name = 'mrp.move' + _order = 'mrp_product_id, mrp_date, mrp_type desc, id' + + # TODO: too many indexes... + + mrp_area_id = fields.Many2one('mrp.area', 'MRP Area') + current_date = fields.Date('Current Date') + current_qty = fields.Float('Current Qty') + # TODO: remove purchase request and move to other module? + # TODO: cancel is not needed I think... + mrp_action = fields.Selection( + selection=[('mo', 'Manufacturing Order'), + ('po', 'Purchase Order'), + ('pr', 'Purchase Request'), + ('so', 'Sale Order'), + ('cancel', 'Cancel'), + ('none', 'None')], + string='Action', + ) + mrp_action_date = fields.Date('MRP Action Date') + mrp_date = fields.Date('MRP Date') + mrp_move_down_ids = fields.Many2many( + comodel_name='mrp.move', + relation='mrp_move_rel', + column1='move_up_id', + column2='move_down_id', + string='MRP Move DOWN', + ) + mrp_move_up_ids = fields.Many2many( + comodel_name='mrp.move', + relation='mrp_move_rel', + column1='move_down_id', + column2='move_up_id', + string='MRP Move UP', + ) + mrp_minimum_stock = fields.Float( + string='Minimum Stock', + related='product_id.mrp_minimum_stock', + ) + mrp_order_number = fields.Char('Order Number') + # TODO: move purchase request to another module + mrp_origin = fields.Selection( + selection=[('mo', 'Manufacturing Order'), + ('po', 'Purchase Order'), + ('pr', 'Purchase Request'), + ('so', 'Sale Order'), + ('mv', 'Move'), + ('fc', 'Forecast'), ('mrp', 'MRP')], + string='Origin') + mrp_processed = fields.Boolean('Processed') + mrp_product_id = fields.Many2one('mrp.product', 'Product', index=True) + mrp_qty = fields.Float('MRP Quantity') + mrp_type = fields.Selection( + selection=[('s', 'Supply'), ('d', 'Demand')], + string='Type', + ) + name = fields.Char('Description') + parent_product_id = fields.Many2one( + comodel_name='product.product', + string='Parent Product', index=True, + ) + product_id = fields.Many2one('product.product', + 'Product', index=True) + production_id = fields.Many2one('mrp.production', + 'Manufacturing Order', index=True) + purchase_line_id = fields.Many2one('purchase.order.line', + 'Purchase Order Line', index=True) + purchase_order_id = fields.Many2one('purchase.order', + 'Purchase Order', index=True) + running_availability = fields.Float('Running Availability') + sale_line_id = fields.Many2one('sale.order.line', + 'Sale Order Line', index=True) + sale_order_id = fields.Many2one('sale.order', 'Sale Order', index=True) + state = fields.Selection( + selection=[('draft', 'Draft'), + ('assigned', 'Assigned'), + ('confirmed', 'Confirmed'), + ('waiting', 'Waiting'), + ('partially_available', 'Partially Available'), + ('ready', 'Ready'), + ('sent', 'Sent'), + ('to approve', 'To Approve'), + ('approved', 'Approved')], + string='State', + ) + stock_move_id = fields.Many2one('stock.move', 'Stock Move', index=True) + + @api.model + def mrp_production_prepare(self, bom_id, routing_id): + return { + 'product_uos_qty': 0.00, + 'product_uom': self.product_id.product_tmpl_id.uom_id.id, + 'product_qty': self.mrp_qty, + 'product_id': self.product_id.id, + 'location_src_id': 12, + 'date_planned': self.mrp_date, + 'cycle_total': 0.00, + 'company_id': 1, + 'state': 'draft', + 'hour_total': 0.00, + 'bom_id': bom_id, + 'routing_id': routing_id, + 'allow_reorder': False + } + + @api.model + def mrp_process_mo(self): + if self.mrp_action != 'mo': + return True + bom_id = False + routing_id = False + mrp_boms = self.env['mrp.bom'].search( + [('product_id', '=', self.product_id.id), + ('type', '=', 'normal')], limit=1) + for mrp_bom in mrp_boms: + bom_id = mrp_bom.id + routing_id = mrp_bom.routing_id.id + + if self.product_id.track_production and self.mrp_qty > 1: + raise exceptions.Warning(_('Not allowed to create ' + 'manufacturing order with ' + 'quantity higher than 1 ' + 'for serialized product')) + else: + production_data = self.mrp_production_prepare(bom_id, routing_id) + pr = self.env['mrp.production'].create(production_data) + self.production_id = pr.id + self.current_qty = self.mrp_qty + self.current_date = self.mrp_date + self.mrp_processed = True + self.name = pr.name + + # TODO: extension to purchase requisition in other module? + @api.model + def mrp_process_pr(self): + if self.mrp_action != 'pr': + return True + seq = self.env['ir.sequence'].search( + [('code', '=', 'purchase.order.requisition')]) + seqnbr = self.env['ir.sequence'].next_by_id(seq.id) + self.env['purchase.requisition'].create({ + 'origin': 'MRP - [' + self.product_id.default_code + '] ' + + self.product_id.name, + 'exclusive': 'exclusive', + 'message_follower_ids': False, + 'date_end': False, + 'date_start': self.mrp_date, + 'company_id': 1, + 'warehouse_id': 1, + 'state': 'draft', + 'line_ids': [[0, False, + {'product_uom_id': + self.product_id.product_tmpl_id.uom_id.id, + 'product_id': self.product_id.id, + 'product_qty': self.mrp_qty, + 'name': self.product_id.name}]], + 'message_ids': False, + 'description': False, + 'name': seqnbr + }) + self.current_qty = self.mrp_qty + self.current_date = self.mrp_date + self.mrp_processed = True + self.name = seqnbr diff --git a/mrp_multi_level/models/mrp_product.py b/mrp_multi_level/models/mrp_product.py new file mode 100644 index 000000000..3572c6608 --- /dev/null +++ b/mrp_multi_level/models/mrp_product.py @@ -0,0 +1,119 @@ +# © 2016 Ucamco - Wim Audenaert +# © 2016-18 Eficent Business and IT Consulting Services S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class MrpProduct(models.Model): + _name = 'mrp.product' + + mrp_area_id = fields.Many2one( + comodel_name='mrp.area', string='MRP Area', + ) + current_qty_available = fields.Float( + string='Current Qty Available', + related='product_id.qty_available', + ) + main_supplier_id = fields.Many2one( + comodel_name='res.partner', string='Main Supplier', + compute='_compute_main_supplier', store=True, + index=True, + ) + mrp_inspection_delay = fields.Integer( + string='Inspection Delay', related='product_id.mrp_inspection_delay') + mrp_lead_time = fields.Float( + string='Lead Time', + related='product_id.produce_delay', + ) + mrp_llc = fields.Integer( + string='Low Level Code', index=True, readonly=True, + ) + # TODO: minimun stock and max/min order qty assigned by area? + mrp_maximum_order_qty = fields.Float( + string='Maximum Order Qty', related='product_id.mrp_maximum_order_qty', + ) + mrp_minimum_order_qty = fields.Float( + string='Minimum Order Qty', related='product_id.mrp_minimum_order_qty', + ) + mrp_minimum_stock = fields.Float( + string='Minimum Stock', + related='product_id.mrp_minimum_stock', + ) + mrp_move_ids = fields.One2many( + comodel_name='mrp.move', inverse_name='mrp_product_id', + string='MRP Moves', + ) + mrp_nbr_days = fields.Integer( + string='Nbr. Days', related='product_id.mrp_nbr_days') + mrp_qty_available = fields.Float('MRP Qty Available') + mrp_qty_multiple = fields.Float(string='Qty Multiple', + related='product_id.mrp_qty_multiple') + # TODO: this was: mrp_transit_delay = fields.Integer(mrp_move_ids) ??¿?¿¿? + mrp_transit_delay = fields.Integer(related='product_id.mrp_transit_delay') + mrp_verified = fields.Boolean(string='MRP Verified', + related='product_id.mrp_verified') + name = fields.Char('Description') + # TODO: rename to mrp_action_count? + nbr_mrp_actions = fields.Integer( + string='Nbr Actions', index=True, + ) + nbr_mrp_actions_4w = fields.Integer( + string='Nbr Actions 4 Weeks', index=True, + ) + product_id = fields.Many2one( + comodel_name='product.product', string='Product', + index=True, + ) + product_tmpl_id = fields.Many2one('product.template', 'Product Template', + related='product_id.product_tmpl_id') + # TODO: extension to purchase requisition in other module? + # purchase_requisition = fields.Boolean(string='Purchase Requisition', + # related='product_id.purchase_requisition') + supply_method = fields.Selection( + selection=[('buy', 'Buy'), + ('none', 'Undefined'), + ('manufacture', 'Produce'), + ('move', 'Transfer')], + string='Supply Method', + compute='_compute_supply_method', store=True, + ) + + @api.multi + @api.depends('mrp_area_id') + def _compute_supply_method(self): + group_obj = self.env['procurement.group'] + for rec in self: + values = { + 'warehouse_id': rec.mrp_area_id.warehouse_id, + 'company_id': self.env.user.company_id.id, # TODO: better way to get company + } + rule = group_obj._get_rule( + rec.product_id, rec.mrp_area_id.location_id, values) + rec.supply_method = rule.action if rule else 'none' + + @api.multi + @api.depends('supply_method') + def _compute_main_supplier(self): + """Simplified and similar to procurement.rule logic.""" + for rec in self.filtered(lambda r: r.supply_method == 'buy'): + suppliers = rec.product_id.seller_ids.filtered( + lambda r: (not r.product_id or r.product_id == rec.product_id)) + if not suppliers: + continue + rec.main_supplier_id = suppliers[0].name + + @api.multi + def _adjust_qty_to_order(self, qty_to_order): + # TODO: consider mrp_qty_multiple? + self.ensure_one() + if not self.mrp_maximum_order_qty and not self.mrp_minimum_order_qty: + return qty_to_order + if qty_to_order < self.mrp_minimum_order_qty: + return self.mrp_minimum_order_qty + if self.mrp_maximum_order_qty and qty_to_order > \ + self.mrp_maximum_order_qty: + qty = self.mrp_maximum_order_qty + else: + qty = qty_to_order + return qty diff --git a/mrp_multi_level/models/product.py b/mrp_multi_level/models/product.py new file mode 100644 index 000000000..21eb147b6 --- /dev/null +++ b/mrp_multi_level/models/product.py @@ -0,0 +1,47 @@ +# © 2016 Ucamco - Wim Audenaert +# © 2016 Eficent Business and IT Consulting Services S.L. +# - Jordi Ballester Alomar +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class Product(models.Model): + _inherit = 'product.product' + + llc = fields.Integer('Low Level Code', default=0) + manufacturing_order_ids = fields.One2many( + comodel_name='mrp.production', + inverse_name='product_id', + string='Manufacturing Orders', + domain=[('state', '=', 'draft')], + ) + mrp_applicable = fields.Boolean('MRP Applicable') + mrp_exclude = fields.Boolean('Exclude from MRP') + mrp_inspection_delay = fields.Integer('Inspection Delay', default=0) + mrp_maximum_order_qty = fields.Float( + string='Maximum Order Qty', default=0.0, + ) + mrp_minimum_order_qty = fields.Float( + string='Minimum Order Qty', default=0.0, + ) + mrp_minimum_stock = fields.Float('Minimum Stock') + mrp_nbr_days = fields.Integer( + string='Nbr. Days', default=0, + help="Number of days to group demand for this product during the " + "MRP run, in order to determine the quantity to order.", + ) + mrp_product_ids = fields.One2many('mrp.product', + 'product_id', 'MRP Product data') + mrp_qty_multiple = fields.Float('Qty Multiple', default=1.00) + mrp_transit_delay = fields.Integer('Transit Delay', default=0) + mrp_verified = fields.Boolean('Verified for MRP', + help="Identifies that this product has " + "been verified to be valid for the " + "MRP.") + purchase_order_line_ids = fields.One2many('purchase.order.line', + 'product_id', 'Purchase Orders') + # TODO: extension to purchase requisition in other module? + # purchase_requisition_ids = fields.One2many('purchase.requisition.line', + # 'product_id', + # 'Purchase Requisitions') diff --git a/mrp_multi_level/models/stock_location.py b/mrp_multi_level/models/stock_location.py new file mode 100644 index 000000000..3a6957b7f --- /dev/null +++ b/mrp_multi_level/models/stock_location.py @@ -0,0 +1,16 @@ +# © 2016 Ucamco - Wim Audenaert +# © 2016 Eficent Business and IT Consulting Services S.L. +# - Jordi Ballester Alomar +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class StockLocation(models.Model): + _inherit = 'stock.location' + + mrp_area_id = fields.Many2one( + comodel_name='mrp.area', string='MRP Area', + help="Requirements for a particular MRP area are combined for the " + "purposes of procurement by the MRP.", + ) diff --git a/mrp_multi_level/security/ir.model.access.csv b/mrp_multi_level/security/ir.model.access.csv new file mode 100644 index 000000000..2e2cafd52 --- /dev/null +++ b/mrp_multi_level/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mrp_inventory_user,mrp.inventory user,model_mrp_inventory,mrp.group_mrp_user,1,0,0,0 +access_mrp_inventory_manager,mrp.inventory manager,model_mrp_inventory,mrp.group_mrp_manager,1,1,1,1 +access_mrp_move_user,mrp.move user,model_mrp_move,mrp.group_mrp_user,1,0,0,0 +access_mrp_move_manager,mrp.move manager,model_mrp_move,mrp.group_mrp_manager,1,1,1,1 +access_mrp_product_user,mrp.product user,model_mrp_product,base.group_user,1,0,0,0 +access_mrp_product_manager,mrp.product manager,model_mrp_product,mrp.group_mrp_manager,1,1,1,1 +access_mrp_area_user,mrp.area user,model_mrp_area,mrp.group_mrp_user,1,0,0,0 +access_mrp_area_manager,mrp.area manager,model_mrp_area,mrp.group_mrp_manager,1,1,1,1 diff --git a/mrp_multi_level/security/mrp_multi_level_security.xml b/mrp_multi_level/security/mrp_multi_level_security.xml new file mode 100644 index 000000000..13a2bedb7 --- /dev/null +++ b/mrp_multi_level/security/mrp_multi_level_security.xml @@ -0,0 +1,10 @@ + + + + + Change procure quantity in MRP + + + + + diff --git a/mrp_multi_level/static/src/img/icon.png b/mrp_multi_level/static/src/img/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/mrp_multi_level/static/src/img/icon.png differ diff --git a/mrp_multi_level/tests/__init__.py b/mrp_multi_level/tests/__init__.py new file mode 100644 index 000000000..aa6799388 --- /dev/null +++ b/mrp_multi_level/tests/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import test_mrp_multi_level diff --git a/mrp_multi_level/tests/test_mrp_multi_level.py b/mrp_multi_level/tests/test_mrp_multi_level.py new file mode 100644 index 000000000..7603218ce --- /dev/null +++ b/mrp_multi_level/tests/test_mrp_multi_level.py @@ -0,0 +1,366 @@ +# Copyright 2018 Eficent Business and IT Consulting Services S.L. +# (http://www.eficent.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import date, datetime, timedelta + +from odoo.tests.common import SavepointCase +from odoo import fields +from dateutil.rrule import WEEKLY + + +class TestMrpMultiLevel(SavepointCase): + + @classmethod + def setUpClass(cls): + super(TestMrpMultiLevel, cls).setUpClass() + cls.mo_obj = cls.env['mrp.production'] + cls.po_obj = cls.env['purchase.order'] + cls.product_obj = cls.env['product.product'] + cls.partner_obj = cls.env['res.partner'] + cls.stock_picking_obj = cls.env['stock.picking'] + cls.estimate_obj = cls.env['stock.demand.estimate'] + cls.mrp_multi_level_wiz = cls.env['mrp.multi.level'] + cls.mrp_inventory_procure_wiz = cls.env['mrp.inventory.procure'] + cls.mrp_inventory_obj = cls.env['mrp.inventory'] + cls.mrp_product_obj = cls.env['mrp.product'] + cls.mrp_move_obj = cls.env['mrp.move'] + + cls.fp_1 = cls.env.ref('mrp_multi_level.product_product_fp_1') + cls.fp_2 = cls.env.ref('mrp_multi_level.product_product_fp_2') + cls.sf_1 = cls.env.ref('mrp_multi_level.product_product_sf_1') + cls.sf_2 = cls.env.ref('mrp_multi_level.product_product_sf_2') + cls.pp_1 = cls.env.ref('mrp_multi_level.product_product_pp_1') + cls.pp_2 = cls.env.ref('mrp_multi_level.product_product_pp_2') + cls.vendor = cls.env.ref('mrp_multi_level.res_partner_lazer_tech') + cls.wh = cls.env.ref('stock.warehouse0') + cls.stock_location = cls.wh.lot_stock_id + cls.customer_location = cls.env.ref( + 'stock.stock_location_customers') + + # Partner: + vendor1 = cls.partner_obj.create({'name': 'Vendor 1'}) + + # Create products: + route_buy = cls.env.ref('purchase.route_warehouse0_buy').id + cls.prod_test = cls.product_obj.create({ + 'name': 'Test Top Seller', + 'type': 'product', + 'list_price': 150.0, + 'produce_delay': 5.0, + 'route_ids': [(6, 0, [route_buy])], + 'seller_ids': [(0, 0, {'name': vendor1.id, 'price': 20.0})], + }) + + # Create test picking: + date_move = datetime.today() + timedelta(days=7) + cls.picking_1 = cls.stock_picking_obj.create({ + 'picking_type_id': cls.env.ref('stock.picking_type_out').id, + 'location_id': cls.stock_location.id, + 'location_dest_id': cls.customer_location.id, + 'move_lines': [ + (0, 0, { + 'name': 'Test move fp-1', + 'product_id': cls.fp_1.id, + 'date_expected': date_move, + 'date': date_move, + 'product_uom': cls.fp_1.uom_id.id, + 'product_uom_qty': 100, + 'location_id': cls.stock_location.id, + 'location_dest_id': cls.customer_location.id + }), + (0, 0, { + 'name': 'Test move fp-2', + 'product_id': cls.fp_2.id, + 'date_expected': date_move, + 'date': date_move, + 'product_uom': cls.fp_2.uom_id.id, + 'product_uom_qty': 15, + 'location_id': cls.stock_location.id, + 'location_dest_id': cls.customer_location.id + })] + }) + cls.picking_1.action_confirm() + + # Create Test PO: + date_po = datetime.today() + timedelta(days=1) + cls.po = cls.po_obj.create({ + 'name': 'Test PO-001', + 'partner_id': cls.vendor.id, + 'order_line': [ + (0, 0, { + 'name': 'Test PP-2 line', + 'product_id': cls.pp_2.id, + 'date_planned': date_po, + 'product_qty': 5.0, + 'product_uom': cls.pp_2.uom_id.id, + 'price_unit': 25.0, + })], + }) + + # Create test MO: + date_mo = datetime.today() + timedelta(days=9) + bom_fp_2 = cls.env.ref('mrp_multi_level.mrp_bom_fp_2') + cls.mo = cls.mo_obj.create({ + 'product_id': cls.fp_2.id, + 'bom_id': bom_fp_2.id, + 'product_qty': 12.0, + 'product_uom_id': cls.fp_2.uom_id.id, + 'date_planned_start': date_mo, + }) + + # Dates (Strings): + today = datetime.today() + cls.date_3 = fields.Date.to_string(today + timedelta(days=3)) + cls.date_5 = fields.Date.to_string(today + timedelta(days=5)) + cls.date_6 = fields.Date.to_string(today + timedelta(days=6)) + cls.date_7 = fields.Date.to_string(today + timedelta(days=7)) + cls.date_8 = fields.Date.to_string(today + timedelta(days=8)) + cls.date_9 = fields.Date.to_string(today + timedelta(days=9)) + cls.date_10 = fields.Date.to_string(today + timedelta(days=10)) + + # Create Date Ranges: + cls.dr_type = cls.env['date.range.type'].create({ + 'name': 'Weeks', + 'company_id': False, + 'allow_overlap': False, + }) + generator = cls.env['date.range.generator'].create({ + 'date_start': today - timedelta(days=3), + 'name_prefix': 'W-', + 'type_id': cls.dr_type.id, + 'duration_count': 1, + 'unit_of_time': WEEKLY, + 'count': 3}) + generator.action_apply() + + # Create Demand Estimates: + ranges = cls.env['date.range'].search( + [('type_id', '=', cls.dr_type.id)]) + qty = 140.0 + for dr in ranges: + qty += 70.0 + cls._create_demand_estimate( + cls.prod_test, cls.stock_location, dr, qty) + + cls.mrp_multi_level_wiz.create({}).run_mrp_multi_level() + + @classmethod + def _create_demand_estimate(cls, product, location, date_range, qty): + cls.estimate_obj.create({ + 'product_id': product.id, + 'location_id': location.id, + 'product_uom': product.uom_id.id, + 'product_uom_qty': qty, + 'date_range_id': date_range.id, + }) + + def test_01_mrp_levels(self): + """Tests computation of MRP levels.""" + self.assertEqual(self.fp_1.llc, 0) + self.assertEqual(self.fp_2.llc, 0) + self.assertEqual(self.sf_1.llc, 1) + self.assertEqual(self.sf_2.llc, 1) + self.assertEqual(self.pp_1.llc, 2) + self.assertEqual(self.pp_2.llc, 2) + + def test_02_mrp_product(self): + """Tests that mrp products are generated correctly.""" + mrp_product = self.mrp_product_obj.search([ + ('product_id', '=', self.pp_1.id)]) + self.assertEqual(mrp_product.supply_method, 'buy') + self.assertEqual(mrp_product.main_supplier_id, self.vendor) + self.assertEqual(mrp_product.mrp_qty_available, 10.0) + mrp_product = self.mrp_product_obj.search([ + ('product_id', '=', self.sf_1.id)]) + self.assertEqual(mrp_product.supply_method, 'manufacture') + + def test_03_mrp_moves(self): + """Tests for mrp moves generated.""" + moves = self.mrp_move_obj.search([ + ('product_id', '=', self.pp_1.id), + ('mrp_action', '=', 'none'), + ]) + self.assertEqual(len(moves), 3) + self.assertNotIn('s', moves.mapped('mrp_type')) + for move in moves: + self.assertTrue(move.mrp_move_up_ids) + if move.mrp_move_up_ids.mrp_product_id.product_id == self.fp_1: + # Demand coming from FP-1 + self.assertEqual(move.mrp_move_up_ids.mrp_action, 'mo') + self.assertEqual(move.mrp_qty, -200.0) + elif move.mrp_move_up_ids.mrp_product_id.product_id == self.sf_1: + # Demand coming from FP-2 -> SF-1 + self.assertEqual(move.mrp_move_up_ids.mrp_action, 'mo') + if move.mrp_date == self.date_5: + self.assertEqual(move.mrp_qty, -90.0) + elif move.mrp_date == self.date_8: + self.assertEqual(move.mrp_qty, -72.0) + # Check actions: + moves = self.mrp_move_obj.search([ + ('product_id', '=', self.pp_1.id), + ('mrp_action', '!=', 'none'), + ]) + self.assertEqual(len(moves), 3) + for move in moves: + self.assertEqual(move.mrp_action, 'po') + self.assertEqual(move.mrp_type, 's') + # Check PP-2 PO being accounted: + po_move = self.mrp_move_obj.search([ + ('product_id', '=', self.pp_2.id), + ('mrp_action', '=', 'none'), + ('mrp_type', '=', 's'), + ]) + self.assertEqual(len(po_move), 1) + self.assertEqual(po_move.purchase_order_id, self.po) + self.assertEqual(po_move.purchase_line_id, self.po.order_line) + + def test_04_mrp_multi_level(self): + """Tests MRP inventories created.""" + # FP-1 + fp_1_inventory_lines = self.mrp_inventory_obj.search( + [('mrp_product_id.product_id', '=', self.fp_1.id)]) + self.assertEqual(len(fp_1_inventory_lines), 1) + self.assertEqual(fp_1_inventory_lines.date, self.date_7) + self.assertEqual(fp_1_inventory_lines.demand_qty, 100.0) + self.assertEqual(fp_1_inventory_lines.to_procure, 100.0) + # FP-2 + fp_2_line_1 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.fp_2.id), + ('date', '=', self.date_7)]) + self.assertEqual(len(fp_2_line_1), 1) + self.assertEqual(fp_2_line_1.demand_qty, 15.0) + self.assertEqual(fp_2_line_1.to_procure, 15.0) + # TODO: ask odoo to fix it... should be date10 + fp_2_line_2 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.fp_2.id), + ('date', '=', self.date_9)]) + self.assertEqual(len(fp_2_line_2), 1) + self.assertEqual(fp_2_line_2.demand_qty, 0.0) + self.assertEqual(fp_2_line_2.to_procure, 0.0) + self.assertEqual(fp_2_line_2.supply_qty, 12.0) + + # SF-1 + sf_1_line_1 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.sf_1.id), + ('date', '=', self.date_6)]) + self.assertEqual(len(sf_1_line_1), 1) + self.assertEqual(sf_1_line_1.demand_qty, 30.0) + self.assertEqual(sf_1_line_1.to_procure, 30.0) + sf_1_line_2 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.sf_1.id), + ('date', '=', self.date_9)]) + self.assertEqual(len(sf_1_line_2), 1) + self.assertEqual(sf_1_line_2.demand_qty, 24.0) + self.assertEqual(sf_1_line_2.to_procure, 24.0) + # SF-2 + sf_2_line_1 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.sf_2.id), + ('date', '=', self.date_6)]) + self.assertEqual(len(sf_2_line_1), 1) + self.assertEqual(sf_2_line_1.demand_qty, 45.0) + self.assertEqual(sf_2_line_1.to_procure, 30.0) + sf_2_line_2 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.sf_2.id), + ('date', '=', self.date_9)]) + self.assertEqual(len(sf_2_line_2), 1) + self.assertEqual(sf_2_line_2.demand_qty, 36.0) + self.assertEqual(sf_2_line_2.to_procure, 36.0) + + # PP-1 + pp_1_line_1 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.pp_1.id), + ('date', '=', self.date_5)]) + self.assertEqual(len(pp_1_line_1), 1) + self.assertEqual(pp_1_line_1.demand_qty, 290.0) + self.assertEqual(pp_1_line_1.to_procure, 280.0) + pp_1_line_2 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.pp_1.id), + ('date', '=', self.date_8)]) + self.assertEqual(len(pp_1_line_2), 1) + self.assertEqual(pp_1_line_2.demand_qty, 72.0) + self.assertEqual(pp_1_line_2.to_procure, 72.0) + # PP-2 + pp_2_line_1 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.pp_2.id), + ('date', '=', self.date_3)]) + self.assertEqual(len(pp_2_line_1), 1) + self.assertEqual(pp_2_line_1.demand_qty, 90.0) + # 90.0 demand - 20.0 on hand - 5.0 on PO = 65.0 + self.assertEqual(pp_2_line_1.to_procure, 65.0) + pp_2_line_2 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.pp_2.id), + ('date', '=', self.date_5)]) + self.assertEqual(len(pp_2_line_2), 1) + self.assertEqual(pp_2_line_2.demand_qty, 360.0) + self.assertEqual(pp_2_line_2.to_procure, 360.0) + pp_2_line_3 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.pp_2.id), + ('date', '=', self.date_6)]) + self.assertEqual(len(pp_2_line_3), 1) + self.assertEqual(pp_2_line_3.demand_qty, 108.0) + self.assertEqual(pp_2_line_3.to_procure, 108.0) + pp_2_line_4 = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.pp_2.id), + ('date', '=', self.date_8)]) + self.assertEqual(len(pp_2_line_4), 1) + self.assertEqual(pp_2_line_4.demand_qty, 48.0) + self.assertEqual(pp_2_line_4.to_procure, 48.0) + + def test_05_moves_extra_info(self): + """Test running availability and actions counters computation on + mrp moves.""" + # Running availability for PP-1: + moves = self.mrp_move_obj.search([ + ('product_id', '=', self.pp_1.id)], + order='mrp_date, mrp_type desc, id') + self.assertEqual(len(moves), 6) + expected = [200.0, 290.0, 90.0, 0.0, 72.0, 0.0] + self.assertEqual(moves.mapped('running_availability'), expected) + # Actions counters for PP-1: + mrp_product = self.mrp_product_obj.search([ + ('product_id', '=', self.pp_1.id) + ]) + self.assertEqual(mrp_product.nbr_mrp_actions, 3) + self.assertEqual(mrp_product.nbr_mrp_actions_4w, 3) + + def test_06_demand_estimates(self): + """Tests demand estimates integration.""" + estimates = self.estimate_obj.search( + [('product_id', '=', self.prod_test.id)]) + self.assertEqual(len(estimates), 3) + moves = self.mrp_move_obj.search([ + ('product_id', '=', self.prod_test.id), + ]) + # 3 weeks - 3 days in the past = 18 days of valid estimates: + moves_from_estimates = moves.filtered(lambda m: m.mrp_type == 'd') + self.assertEqual(len(moves_from_estimates), 18) + quantities = moves_from_estimates.mapped('mrp_qty') + self.assertIn(-30.0, quantities) # 210 a week => 30.0 dayly: + self.assertIn(-40.0, quantities) # 280 a week => 40.0 dayly: + self.assertIn(-50.0, quantities) # 350 a week => 50.0 dayly: + actions = moves.filtered(lambda m: m.mrp_action == 'po') + self.assertEqual(len(actions), 18) + + def test_07_procure_mo(self): + """Test procurement wizard with MOs.""" + mos = self.mo_obj.search([ + ('product_id', '=', self.fp_1.id)]) + self.assertFalse(mos) + mrp_inv = self.mrp_inventory_obj.search([ + ('mrp_product_id.product_id', '=', self.fp_1.id)]) + self.mrp_inventory_procure_wiz.with_context({ + 'active_model': 'mrp.inventory', + 'active_ids': mrp_inv.ids, + 'active_id': mrp_inv.id, + }).create({}).make_procurement() + mos = self.mo_obj.search([ + ('product_id', '=', self.fp_1.id)]) + self.assertTrue(mos) + self.assertEqual(mos.product_qty, 100.0) + datetime_5 = fields.Datetime.to_string( + date.today() + timedelta(days=5)) + self.assertEqual(mos.date_planned_start, datetime_5) + + # TODO: test procure wizard: pos, multiple... + # TODO: test multiple destination IDS:... \ No newline at end of file diff --git a/mrp_multi_level/views/mrp_area_view.xml b/mrp_multi_level/views/mrp_area_view.xml new file mode 100644 index 000000000..9bc182e0f --- /dev/null +++ b/mrp_multi_level/views/mrp_area_view.xml @@ -0,0 +1,60 @@ + + + + + mrp.area.tree + mrp.area + form + + + + + + + + + + + mrp.area.form + mrp.area + form + +
+ + + + + + + + + + +
+
+
+ + + + MRP Area + mrp.area + ir.actions.act_window + form + tree,form + + + + + + form + + + + + + tree + + + + +
diff --git a/mrp_multi_level/views/mrp_inventory_view.xml b/mrp_multi_level/views/mrp_inventory_view.xml new file mode 100644 index 000000000..b536e831a --- /dev/null +++ b/mrp_multi_level/views/mrp_inventory_view.xml @@ -0,0 +1,97 @@ + + + + + mrp.inventory.form + mrp.inventory + form + +
+ + + + + + + + + + +
+
+
+ + + mrp.inventory.tree + mrp.inventory + form + + + + + + + + + + + +