diff --git a/maintenance_equipment_spareparts/README.rst b/maintenance_equipment_spareparts/README.rst new file mode 100644 index 000000000..1006a4518 --- /dev/null +++ b/maintenance_equipment_spareparts/README.rst @@ -0,0 +1,167 @@ +================================= +Maintenance Equipment Spare Parts +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e9ba4026a2fd2a8e524abcfac0f4d28de45177ff8574cd0c8a7e44172388d2d7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmaintenance-lightgray.png?logo=github + :target: https://github.com/OCA/maintenance/tree/18.0/maintenance_equipment_spareparts + :alt: OCA/maintenance +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/maintenance-18-0/maintenance-18-0-maintenance_equipment_spareparts + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/maintenance&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module manages spare parts for maintenance equipment, allowing you +to: + +- Define spare parts for each equipment with installed and recommended + spare quantities +- Track spare parts consumption during maintenance requests +- Automatically create stock movements when spare parts are consumed (if + stock is available) +- Automatically create purchase requests when spare parts are needed but + not in stock +- Restrict purchase requisitions to only registered spare parts for the + selected equipment +- Monitor stock levels and alert when quantities fall below recommended + spare quantities + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Equipment Spare Parts +===================== + +To manage spare parts for an equipment: + +- Go to *Maintenance > Equipments* and open an equipment form +- Click on the *Spare Parts* tab +- Click *Add a line* to add a new spare part +- Select a *Product* from the catalog +- Enter the *Installed Quantity* (quantity used by the equipment) +- Enter the *Recommended Spare Quantity* (minimum quantity to keep in + stock) +- Save the record + +The system will automatically calculate and display: + +- Available quantity in stock +- Alert indicator when stock falls below recommended spare quantity + +Adding Spare Parts During Maintenance +===================================== + +When performing maintenance: + +- Create or open a maintenance request for an equipment +- Go to the *Spare Parts* section +- If you identify a spare part that is not yet registered, you can add + it directly from the request +- The spare part will be automatically added to the equipment's spare + parts list + +Consuming Spare Parts +===================== + +During maintenance, you can consume spare parts: + +- In a maintenance request, click *Consume Spare Parts* +- A wizard will open showing all spare parts for the equipment +- Enter the quantity needed for each spare part +- Click *Consume* + +The system will: + +- If stock is available: automatically create a stock picking and + movement +- If stock is not available: automatically create a purchase request + with the needed quantity + +Purchase Requisition Integration +================================ + +When creating a purchase requisition for equipment spare parts: + +- Select the equipment in the purchase requisition form +- The product selection will be restricted to only spare parts + registered for that equipment +- You cannot add products that are not registered as spare parts for the + selected equipment +- If you try to add a non-registered product, the system will prevent it + and inform you that the product must be registered as a spare part + first + +Stock Alerts +============ + +The system monitors spare parts stock levels: + +- When the available quantity falls below the recommended spare + quantity, an alert indicator is shown +- You can use the *Create Purchase Request* button to automatically + generate a purchase request for all parts needing replenishment + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* KMEE + +Contributors +------------ + +Contributors +~~~~~~~~~~~~ + +- Luis Felipe Miléo mileo@kmee.com.br + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/maintenance `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/maintenance_equipment_spareparts/__init__.py b/maintenance_equipment_spareparts/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/maintenance_equipment_spareparts/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/maintenance_equipment_spareparts/__manifest__.py b/maintenance_equipment_spareparts/__manifest__.py new file mode 100644 index 000000000..3df970aa7 --- /dev/null +++ b/maintenance_equipment_spareparts/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Maintenance Equipment Spare Parts", + "summary": "Manage spare parts for maintenance equipment with " + "purchase requisition integration", + "version": "18.0.1.0.0", + "category": "Maintenance", + "website": "https://github.com/OCA/maintenance", + "author": "KMEE, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "base_maintenance", + "maintenance_product", + "maintenance_stock", + "purchase_request", + "stock", + ], + "data": [ + "security/ir.model.access.csv", + "views/maintenance_equipment_sparepart_views.xml", + "views/maintenance_equipment_views.xml", + "views/maintenance_request_sparepart_consumption_views.xml", + "views/maintenance_request_views.xml", + "views/purchase_request_views.xml", + "wizards/maintenance_request_consume_spareparts_wizard.xml", + ], + "installable": True, + "development_status": "Beta", +} diff --git a/maintenance_equipment_spareparts/i18n/pt_BR.po b/maintenance_equipment_spareparts/i18n/pt_BR.po new file mode 100644 index 000000000..3bae37344 --- /dev/null +++ b/maintenance_equipment_spareparts/i18n/pt_BR.po @@ -0,0 +1,116 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * maintenance_equipment_spareparts +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \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: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_equipment__sparepart_count +msgid "Spare Parts Count" +msgstr "Quantidade de Peças" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_equipment__sparepart_ids +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_request__sparepart_ids +msgid "Spare Parts" +msgstr "Peças" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__available_qty +msgid "Available Quantity" +msgstr "Quantidade Disponível" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__installed_qty +msgid "Installed Quantity" +msgstr "Quantidade Instalada" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__needs_reorder +msgid "Needs Reorder" +msgstr "Precisa Repor" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__spare_qty +msgid "Recommended Spare Quantity" +msgstr "Quantidade Sobressalente Recomendada" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_request_sparepart_consumption__consumed_qty +msgid "Consumed Quantity" +msgstr "Quantidade Consumida" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_request_sparepart_consumption__state +msgid "State" +msgstr "Estado" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_request_sparepart_consumption__stock_picking_id +msgid "Stock Picking" +msgstr "Movimentação de Estoque" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_request_sparepart_consumption__purchase_request_id +msgid "Purchase Request" +msgstr "Requisição de Compra" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_purchase_request__equipment_id +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_purchase_request_line__equipment_id +msgid "Equipment" +msgstr "Equipamento" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,field_description:maintenance_equipment_spareparts.field_maintenance_request__sparepart_consumption_ids +msgid "Spare Parts Consumption" +msgstr "Consumo de Peças" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__available_qty +msgid "Available quantity in stock" +msgstr "Quantidade disponível em estoque" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__needs_reorder +msgid "True if stock is below recommended spare quantity" +msgstr "Verdadeiro se o estoque está abaixo da quantidade sobressalente recomendada" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__installed_qty +msgid "Quantity of this part installed in the equipment" +msgstr "Quantidade desta peça instalada no equipamento" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_maintenance_equipment_sparepart__spare_qty +msgid "Recommended quantity to keep in stock as spare" +msgstr "Quantidade recomendada para manter em estoque como sobressalente" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_purchase_request__equipment_id +msgid "When set, product selection will be restricted to spare parts registered for this equipment" +msgstr "Quando definido, a seleção de produtos será restrita às peças registradas para este equipamento" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_maintenance_request_sparepart_consumption__stock_picking_id +msgid "Stock picking created when part was consumed from stock" +msgstr "Movimentação de estoque criada quando a peça foi consumida do estoque" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_maintenance_request_sparepart_consumption__purchase_request_id +msgid "Purchase request created when part was not in stock" +msgstr "Requisição de compra criada quando a peça não estava em estoque" + +#. module: maintenance_equipment_spareparts +#: model:ir.model.fields,help:maintenance_equipment_spareparts.field_maintenance_request_sparepart_consumption__purchase_request_line_id +msgid "Purchase request line created when part was not in stock" +msgstr "Linha de requisição de compra criada quando a peça não estava em estoque" diff --git a/maintenance_equipment_spareparts/models/__init__.py b/maintenance_equipment_spareparts/models/__init__.py new file mode 100644 index 000000000..7eda987f8 --- /dev/null +++ b/maintenance_equipment_spareparts/models/__init__.py @@ -0,0 +1,5 @@ +from . import maintenance_equipment_sparepart +from . import maintenance_equipment +from . import maintenance_request +from . import maintenance_request_sparepart_consumption +from . import purchase_request diff --git a/maintenance_equipment_spareparts/models/maintenance_equipment.py b/maintenance_equipment_spareparts/models/maintenance_equipment.py new file mode 100644 index 000000000..b22ef7812 --- /dev/null +++ b/maintenance_equipment_spareparts/models/maintenance_equipment.py @@ -0,0 +1,104 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models + + +class MaintenanceEquipment(models.Model): + _inherit = "maintenance.equipment" + + sparepart_ids = fields.One2many( + comodel_name="maintenance.equipment.sparepart", + inverse_name="equipment_id", + string="Spare Parts", + ) + sparepart_count = fields.Integer( + string="Spare Parts Count", + compute="_compute_sparepart_count", + store=True, + ) + + @api.depends("sparepart_ids") + def _compute_sparepart_count(self): + for record in self: + record.sparepart_count = len(record.sparepart_ids.filtered("active")) + + def action_view_spareparts(self): + self.ensure_one() + action = { + "name": "Spare Parts", + "type": "ir.actions.act_window", + "res_model": "maintenance.equipment.sparepart", + "view_mode": "tree,form", + "domain": [("equipment_id", "=", self.id)], + "context": {"default_equipment_id": self.id}, + } + return action + + def action_create_purchase_request(self): + """Create purchase request for spare parts below recommended quantity.""" + self.ensure_one() + spareparts_needing_reorder = self.sparepart_ids.filtered("needs_reorder") + if not spareparts_needing_reorder: + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("No Action Needed"), + "message": _("All spare parts have sufficient stock."), + "type": "success", + }, + } + picking_type = self.env["stock.picking.type"].search( + [ + ("code", "=", "incoming"), + ("warehouse_id.company_id", "=", self.company_id.id), + ], + limit=1, + ) + if not picking_type: + picking_type = self.env["stock.picking.type"].search( + [("code", "=", "incoming"), ("warehouse_id", "=", False)], limit=1 + ) + purchase_request = self.env["purchase.request"].create( + { + "origin": _("Equipment: %s") % self.display_name, + "requested_by": self.env.user.id, + "company_id": self.company_id.id, + "picking_type_id": picking_type.id if picking_type else False, + "equipment_id": self.id, + } + ) + lines_created = False + for sparepart in spareparts_needing_reorder: + qty_needed = sparepart.spare_qty - sparepart.available_qty + if qty_needed > 0: + self.env["purchase.request.line"].create( + { + "request_id": purchase_request.id, + "product_id": sparepart.product_id.id, + "product_uom_id": sparepart.product_id.uom_id.id, + "product_qty": qty_needed, + "name": sparepart.product_id.display_name, + } + ) + lines_created = True + if not lines_created: + # No lines were created, cancel the request + purchase_request.unlink() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("No Action Needed"), + "message": _("All spare parts have sufficient stock."), + "type": "success", + }, + } + return { + "name": _("Purchase Request"), + "type": "ir.actions.act_window", + "res_model": "purchase.request", + "view_mode": "form", + "res_id": purchase_request.id, + "target": "current", + } diff --git a/maintenance_equipment_spareparts/models/maintenance_equipment_sparepart.py b/maintenance_equipment_spareparts/models/maintenance_equipment_sparepart.py new file mode 100644 index 000000000..d3a822447 --- /dev/null +++ b/maintenance_equipment_spareparts/models/maintenance_equipment_sparepart.py @@ -0,0 +1,104 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class MaintenanceEquipmentSparepart(models.Model): + _name = "maintenance.equipment.sparepart" + _description = "Maintenance Equipment Spare Part" + _rec_name = "product_id" + _order = "product_id" + + equipment_id = fields.Many2one( + comodel_name="maintenance.equipment", + string="Equipment", + required=True, + ondelete="cascade", + index=True, + ) + product_id = fields.Many2one( + comodel_name="product.product", + string="Product", + required=True, + domain="[('purchase_ok', '=', True)]", + ) + installed_qty = fields.Float( + string="Installed Quantity", + required=True, + default=0.0, + help="Quantity of this part installed in the equipment", + ) + spare_qty = fields.Float( + string="Recommended Spare Quantity", + required=True, + default=0.0, + help="Recommended quantity to keep in stock as spare", + ) + company_id = fields.Many2one( + comodel_name="res.company", + related="equipment_id.company_id", + store=True, + readonly=True, + ) + active = fields.Boolean(default=True) + available_qty = fields.Float( + string="Available Quantity", + compute="_compute_available_qty", + help="Available quantity in stock", + ) + needs_reorder = fields.Boolean( + compute="_compute_needs_reorder", + help="True if stock is below recommended spare quantity", + ) + + @api.depends("product_id", "product_id.qty_available") + def _compute_available_qty(self): + for record in self: + if record.product_id: + record.available_qty = record.product_id.qty_available + else: + record.available_qty = 0.0 + + @api.depends("available_qty", "spare_qty") + def _compute_needs_reorder(self): + for record in self: + record.needs_reorder = record.available_qty < record.spare_qty + + @api.constrains("installed_qty", "spare_qty") + def _check_quantities(self): + for record in self: + if record.installed_qty < 0: + raise ValidationError( + _("Installed quantity must be greater than or equal to 0.") + ) + if record.spare_qty < 0: + raise ValidationError( + _( + "Recommended spare quantity must be greater than or " + "equal to 0." + ) + ) + + @api.constrains("equipment_id", "product_id") + def _check_unique_product(self): + for record in self: + existing = self.search( + [ + ("equipment_id", "=", record.equipment_id.id), + ("product_id", "=", record.product_id.id), + ("id", "!=", record.id), + ("active", "=", True), + ] + ) + if existing: + raise ValidationError( + _( + "Product %(product)s is already registered as a spare " + "part for equipment %(equipment)s." + ) + % { + "product": record.product_id.display_name, + "equipment": record.equipment_id.display_name, + } + ) diff --git a/maintenance_equipment_spareparts/models/maintenance_request.py b/maintenance_equipment_spareparts/models/maintenance_request.py new file mode 100644 index 000000000..ee3bf0175 --- /dev/null +++ b/maintenance_equipment_spareparts/models/maintenance_request.py @@ -0,0 +1,121 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class MaintenanceRequest(models.Model): + _inherit = "maintenance.request" + + sparepart_ids = fields.Many2many( + comodel_name="maintenance.equipment.sparepart", + string="Equipment Spare Parts", + compute="_compute_sparepart_ids", + readonly=True, + ) + + @api.depends("equipment_id", "equipment_id.sparepart_ids") + def _compute_sparepart_ids(self): + for record in self: + if record.equipment_id: + record.sparepart_ids = record.equipment_id.sparepart_ids + else: + record.sparepart_ids = False + + def action_consume_spareparts(self): + self.ensure_one() + if not self.equipment_id: + raise UserError(_("Equipment must be set to consume spare parts.")) + return { + "name": _("Consume Spare Parts"), + "type": "ir.actions.act_window", + "res_model": "maintenance.request.consume.spareparts", + "view_mode": "form", + "target": "new", + "context": { + "default_request_id": self.id, + }, + } + + def _create_stock_picking_for_sparepart(self, sparepart, qty): + """Create stock picking for consumption of spare part.""" + self.ensure_one() + if not self.default_consumption_warehouse_id: + raise UserError( + _( + "Default consumption warehouse must be set on equipment " + "to consume spare parts from stock." + ) + ) + warehouse = self.default_consumption_warehouse_id + cons_type = warehouse.cons_type_id + if not cons_type: + raise UserError( + _("Consumption picking type must be configured on warehouse %s.") + % warehouse.display_name + ) + picking_vals = { + "picking_type_id": cons_type.id, + "location_id": cons_type.default_location_src_id.id, + "location_dest_id": cons_type.default_location_dest_id.id, + "maintenance_request_id": self.id, + "maintenance_equipment_id": self.equipment_id.id, + "origin": _("Maintenance Request %s") % self.name, + } + picking = self.env["stock.picking"].create(picking_vals) + move_vals = { + "name": sparepart.product_id.display_name, + "product_id": sparepart.product_id.id, + "product_uom": sparepart.product_id.uom_id.id, + "product_uom_qty": qty, + "picking_id": picking.id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + } + move = self.env["stock.move"].create(move_vals) + move._action_confirm() + picking._autoconfirm_picking() + return picking + + def _create_purchase_request_for_sparepart(self, sparepart, qty): + """Create purchase request for spare part not in stock.""" + self.ensure_one() + # Find or create purchase request + purchase_request = self.env["purchase.request"].search( + [ + ("origin", "=", self.name), + ("state", "in", ["draft", "to_approve"]), + ("company_id", "=", self.company_id.id), + ], + limit=1, + ) + if not purchase_request: + picking_type = self.env["stock.picking.type"].search( + [ + ("code", "=", "incoming"), + ("warehouse_id.company_id", "=", self.company_id.id), + ], + limit=1, + ) + if not picking_type: + picking_type = self.env["stock.picking.type"].search( + [("code", "=", "incoming"), ("warehouse_id", "=", False)], limit=1 + ) + purchase_request = self.env["purchase.request"].create( + { + "origin": self.name, + "requested_by": self.env.user.id, + "company_id": self.company_id.id, + "picking_type_id": picking_type.id if picking_type else False, + "equipment_id": self.equipment_id.id, + } + ) + line_vals = { + "request_id": purchase_request.id, + "product_id": sparepart.product_id.id, + "product_uom_id": sparepart.product_id.uom_id.id, + "product_qty": qty, + "name": sparepart.product_id.display_name, + } + line = self.env["purchase.request.line"].create(line_vals) + return purchase_request, line diff --git a/maintenance_equipment_spareparts/models/maintenance_request_sparepart_consumption.py b/maintenance_equipment_spareparts/models/maintenance_request_sparepart_consumption.py new file mode 100644 index 000000000..b35b1215b --- /dev/null +++ b/maintenance_equipment_spareparts/models/maintenance_request_sparepart_consumption.py @@ -0,0 +1,64 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class MaintenanceRequestSparepartConsumption(models.Model): + _name = "maintenance.request.sparepart.consumption" + _description = "Maintenance Request Spare Part Consumption" + _rec_name = "product_id" + _order = "create_date desc" + + request_id = fields.Many2one( + comodel_name="maintenance.request", + string="Maintenance Request", + required=True, + ondelete="cascade", + index=True, + ) + sparepart_id = fields.Many2one( + comodel_name="maintenance.equipment.sparepart", + string="Spare Part", + required=True, + ) + product_id = fields.Many2one( + comodel_name="product.product", + string="Product", + related="sparepart_id.product_id", + store=True, + readonly=True, + ) + consumed_qty = fields.Float( + string="Consumed Quantity", + required=True, + ) + stock_picking_id = fields.Many2one( + comodel_name="stock.picking", + string="Stock Picking", + help="Stock picking created when part was consumed from stock", + ) + purchase_request_id = fields.Many2one( + comodel_name="purchase.request", + string="Purchase Request", + help="Purchase request created when part was not in stock", + ) + purchase_request_line_id = fields.Many2one( + comodel_name="purchase.request.line", + string="Purchase Request Line", + help="Purchase request line created when part was not in stock", + ) + state = fields.Selection( + selection=[ + ("consumed", "Consumed from Stock"), + ("requested", "Purchase Request Created"), + ("pending", "Pending"), + ], + default="pending", + required=True, + ) + company_id = fields.Many2one( + comodel_name="res.company", + related="request_id.company_id", + store=True, + readonly=True, + ) diff --git a/maintenance_equipment_spareparts/models/purchase_request.py b/maintenance_equipment_spareparts/models/purchase_request.py new file mode 100644 index 000000000..6076bb18a --- /dev/null +++ b/maintenance_equipment_spareparts/models/purchase_request.py @@ -0,0 +1,68 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PurchaseRequest(models.Model): + _inherit = "purchase.request" + + equipment_id = fields.Many2one( + comodel_name="maintenance.equipment", + string="Equipment", + help="When set, product selection will be restricted to spare parts " + "registered for this equipment", + ) + + +class PurchaseRequestLine(models.Model): + _inherit = "purchase.request.line" + + equipment_id = fields.Many2one( + comodel_name="maintenance.equipment", + string="Equipment", + related="request_id.equipment_id", + store=True, + readonly=True, + ) + + @api.onchange("request_id") + def _onchange_request_id(self): + """Restrict product domain when equipment is set.""" + domain = [("purchase_ok", "=", True)] + if self.request_id and self.request_id.equipment_id: + sparepart_products = self.request_id.equipment_id.sparepart_ids.mapped( + "product_id" + ) + if sparepart_products: + domain.append(("id", "in", sparepart_products.ids)) + else: + # No spareparts registered, show empty domain to prevent selection + domain.append(("id", "=", False)) + return {"domain": {"product_id": domain}} + + @api.constrains("equipment_id", "product_id") + def _check_product_is_sparepart(self): + """Ensure product is a registered spare part when equipment is set.""" + for record in self: + if record.equipment_id and record.product_id: + sparepart = self.env["maintenance.equipment.sparepart"].search( + [ + ("equipment_id", "=", record.equipment_id.id), + ("product_id", "=", record.product_id.id), + ("active", "=", True), + ], + limit=1, + ) + if not sparepart: + raise ValidationError( + _( + "Product %(product)s must be registered as a spare " + "part for equipment %(equipment)s before it can be " + "requested. Please add it as a spare part first." + ) + % { + "product": record.product_id.display_name, + "equipment": record.equipment_id.display_name, + } + ) diff --git a/maintenance_equipment_spareparts/pyproject.toml b/maintenance_equipment_spareparts/pyproject.toml new file mode 100644 index 000000000..2e1ab1605 --- /dev/null +++ b/maintenance_equipment_spareparts/pyproject.toml @@ -0,0 +1,6 @@ +[ tool.black ] +line-length = 88 + +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/maintenance_equipment_spareparts/readme/CONTRIBUTORS.md b/maintenance_equipment_spareparts/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..daee7c0b1 --- /dev/null +++ b/maintenance_equipment_spareparts/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +Contributors +------------ + +- Luis Felipe Miléo diff --git a/maintenance_equipment_spareparts/readme/DESCRIPTION.md b/maintenance_equipment_spareparts/readme/DESCRIPTION.md new file mode 100644 index 000000000..ea278912f --- /dev/null +++ b/maintenance_equipment_spareparts/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module manages spare parts for maintenance equipment, allowing you to: + +* Define spare parts for each equipment with installed and recommended spare quantities +* Track spare parts consumption during maintenance requests +* Automatically create stock movements when spare parts are consumed (if stock is available) +* Automatically create purchase requests when spare parts are needed but not in stock +* Restrict purchase requisitions to only registered spare parts for the selected equipment +* Monitor stock levels and alert when quantities fall below recommended spare quantities diff --git a/maintenance_equipment_spareparts/readme/USAGE.md b/maintenance_equipment_spareparts/readme/USAGE.md new file mode 100644 index 000000000..018390d0d --- /dev/null +++ b/maintenance_equipment_spareparts/readme/USAGE.md @@ -0,0 +1,58 @@ +Equipment Spare Parts +===================== + +To manage spare parts for an equipment: + +* Go to *Maintenance > Equipments* and open an equipment form +* Click on the *Spare Parts* tab +* Click *Add a line* to add a new spare part +* Select a *Product* from the catalog +* Enter the *Installed Quantity* (quantity used by the equipment) +* Enter the *Recommended Spare Quantity* (minimum quantity to keep in stock) +* Save the record + +The system will automatically calculate and display: +* Available quantity in stock +* Alert indicator when stock falls below recommended spare quantity + +Adding Spare Parts During Maintenance +====================================== + +When performing maintenance: + +* Create or open a maintenance request for an equipment +* Go to the *Spare Parts* section +* If you identify a spare part that is not yet registered, you can add it directly from the request +* The spare part will be automatically added to the equipment's spare parts list + +Consuming Spare Parts +===================== + +During maintenance, you can consume spare parts: + +* In a maintenance request, click *Consume Spare Parts* +* A wizard will open showing all spare parts for the equipment +* Enter the quantity needed for each spare part +* Click *Consume* + +The system will: +* If stock is available: automatically create a stock picking and movement +* If stock is not available: automatically create a purchase request with the needed quantity + +Purchase Requisition Integration +================================= + +When creating a purchase requisition for equipment spare parts: + +* Select the equipment in the purchase requisition form +* The product selection will be restricted to only spare parts registered for that equipment +* You cannot add products that are not registered as spare parts for the selected equipment +* If you try to add a non-registered product, the system will prevent it and inform you that the product must be registered as a spare part first + +Stock Alerts +============ + +The system monitors spare parts stock levels: + +* When the available quantity falls below the recommended spare quantity, an alert indicator is shown +* You can use the *Create Purchase Request* button to automatically generate a purchase request for all parts needing replenishment diff --git a/maintenance_equipment_spareparts/security/ir.model.access.csv b/maintenance_equipment_spareparts/security/ir.model.access.csv new file mode 100644 index 000000000..85ec92899 --- /dev/null +++ b/maintenance_equipment_spareparts/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_maintenance_equipment_sparepart,access_maintenance_equipment_sparepart,model_maintenance_equipment_sparepart,base.group_user,1,0,0,0 +access_maintenance_equipment_sparepart_admin,access_maintenance_equipment_sparepart_admin,model_maintenance_equipment_sparepart,maintenance.group_equipment_manager,1,1,1,1 +access_maintenance_request_sparepart_consumption,access_maintenance_request_sparepart_consumption,model_maintenance_request_sparepart_consumption,base.group_user,1,0,0,0 +access_maintenance_request_sparepart_consumption_admin,access_maintenance_request_sparepart_consumption_admin,model_maintenance_request_sparepart_consumption,maintenance.group_equipment_manager,1,1,1,1 +access_maintenance_request_consume_spareparts,access_maintenance_request_consume_spareparts,model_maintenance_request_consume_spareparts,maintenance.group_equipment_manager,1,1,1,1 +access_maintenance_request_consume_spareparts_line,access_maintenance_request_consume_spareparts_line,model_maintenance_request_consume_spareparts_line,base.group_user,1,0,0,0 +access_maintenance_request_consume_spareparts_line_admin,access_maintenance_request_consume_spareparts_line_admin,model_maintenance_request_consume_spareparts_line,maintenance.group_equipment_manager,1,1,1,1 diff --git a/maintenance_equipment_spareparts/static/description/index.html b/maintenance_equipment_spareparts/static/description/index.html new file mode 100644 index 000000000..727a5e28f --- /dev/null +++ b/maintenance_equipment_spareparts/static/description/index.html @@ -0,0 +1,524 @@ + + + + + +Maintenance Equipment Spare Parts + + + +
+

Maintenance Equipment Spare Parts

+ + +

Beta License: AGPL-3 OCA/maintenance Translate me on Weblate Try me on Runboat

+

This module manages spare parts for maintenance equipment, allowing you +to:

+
    +
  • Define spare parts for each equipment with installed and recommended +spare quantities
  • +
  • Track spare parts consumption during maintenance requests
  • +
  • Automatically create stock movements when spare parts are consumed (if +stock is available)
  • +
  • Automatically create purchase requests when spare parts are needed but +not in stock
  • +
  • Restrict purchase requisitions to only registered spare parts for the +selected equipment
  • +
  • Monitor stock levels and alert when quantities fall below recommended +spare quantities
  • +
+

Table of contents

+ +
+

Usage

+
+
+

Equipment Spare Parts

+

To manage spare parts for an equipment:

+
    +
  • Go to Maintenance > Equipments and open an equipment form
  • +
  • Click on the Spare Parts tab
  • +
  • Click Add a line to add a new spare part
  • +
  • Select a Product from the catalog
  • +
  • Enter the Installed Quantity (quantity used by the equipment)
  • +
  • Enter the Recommended Spare Quantity (minimum quantity to keep in +stock)
  • +
  • Save the record
  • +
+

The system will automatically calculate and display:

+
    +
  • Available quantity in stock
  • +
  • Alert indicator when stock falls below recommended spare quantity
  • +
+
+
+

Adding Spare Parts During Maintenance

+

When performing maintenance:

+
    +
  • Create or open a maintenance request for an equipment
  • +
  • Go to the Spare Parts section
  • +
  • If you identify a spare part that is not yet registered, you can add +it directly from the request
  • +
  • The spare part will be automatically added to the equipment’s spare +parts list
  • +
+
+
+

Consuming Spare Parts

+

During maintenance, you can consume spare parts:

+
    +
  • In a maintenance request, click Consume Spare Parts
  • +
  • A wizard will open showing all spare parts for the equipment
  • +
  • Enter the quantity needed for each spare part
  • +
  • Click Consume
  • +
+

The system will:

+
    +
  • If stock is available: automatically create a stock picking and +movement
  • +
  • If stock is not available: automatically create a purchase request +with the needed quantity
  • +
+
+
+

Purchase Requisition Integration

+

When creating a purchase requisition for equipment spare parts:

+
    +
  • Select the equipment in the purchase requisition form
  • +
  • The product selection will be restricted to only spare parts +registered for that equipment
  • +
  • You cannot add products that are not registered as spare parts for the +selected equipment
  • +
  • If you try to add a non-registered product, the system will prevent it +and inform you that the product must be registered as a spare part +first
  • +
+
+
+

Stock Alerts

+

The system monitors spare parts stock levels:

+
    +
  • When the available quantity falls below the recommended spare +quantity, an alert indicator is shown
  • +
  • You can use the Create Purchase Request button to automatically +generate a purchase request for all parts needing replenishment
  • +
+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • KMEE
  • +
+
+
+

Contributors

+
+

Contributors

+ +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

This module is part of the OCA/maintenance project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/maintenance_equipment_spareparts/tests/__init__.py b/maintenance_equipment_spareparts/tests/__init__.py new file mode 100644 index 000000000..cae48d4bb --- /dev/null +++ b/maintenance_equipment_spareparts/tests/__init__.py @@ -0,0 +1 @@ +from . import test_maintenance_equipment_spareparts diff --git a/maintenance_equipment_spareparts/tests/test_maintenance_equipment_spareparts.py b/maintenance_equipment_spareparts/tests/test_maintenance_equipment_spareparts.py new file mode 100644 index 000000000..bec83a3c3 --- /dev/null +++ b/maintenance_equipment_spareparts/tests/test_maintenance_equipment_spareparts.py @@ -0,0 +1,188 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestMaintenanceEquipmentSpareparts(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Equipment = cls.env["maintenance.equipment"] + cls.Sparepart = cls.env["maintenance.equipment.sparepart"] + cls.Request = cls.env["maintenance.request"] + cls.Product = cls.env["product.product"] + cls.Warehouse = cls.env["stock.warehouse"] + + # Create test product + cls.product = cls.Product.create( + { + "name": "Test Spare Part", + "purchase_ok": True, + "type": "consu", + "is_storable": True, + } + ) + + # Create test equipment + cls.equipment = cls.Equipment.create( + { + "name": "Test Equipment", + } + ) + + # Create warehouse for stock operations + cls.warehouse = cls.env.ref("stock.warehouse0") + + def test_create_sparepart(self): + """Test creating a spare part.""" + sparepart = self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": 2.0, + "spare_qty": 5.0, + } + ) + self.assertEqual(sparepart.equipment_id, self.equipment) + self.assertEqual(sparepart.product_id, self.product) + self.assertEqual(sparepart.installed_qty, 2.0) + self.assertEqual(sparepart.spare_qty, 5.0) + + def test_duplicate_sparepart_validation(self): + """Test that duplicate spare parts are not allowed.""" + self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": 2.0, + "spare_qty": 5.0, + } + ) + with self.assertRaises(ValidationError): + self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": 1.0, + "spare_qty": 3.0, + } + ) + + def test_negative_quantity_validation(self): + """Test that negative quantities are not allowed.""" + with self.assertRaises(ValidationError): + self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": -1.0, + "spare_qty": 5.0, + } + ) + + def test_needs_reorder_computation(self): + """Test needs_reorder computed field.""" + sparepart = self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": 2.0, + "spare_qty": 5.0, + } + ) + # Initially should need reorder if no stock + sparepart.invalidate_recordset(["needs_reorder"]) + self.assertTrue(sparepart.needs_reorder) + + def test_sparepart_count(self): + """Test sparepart_count computation.""" + initial_count = self.equipment.sparepart_count + self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": 2.0, + "spare_qty": 5.0, + } + ) + self.equipment.invalidate_recordset(["sparepart_count"]) + self.assertEqual(self.equipment.sparepart_count, initial_count + 1) + + def test_purchase_request_product_restriction(self): + """Test that purchase request line validates product is sparepart.""" + # Create sparepart + self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": 2.0, + "spare_qty": 5.0, + } + ) + # Create purchase request with equipment + purchase_request = self.env["purchase.request"].create( + { + "requested_by": self.env.user.id, + "equipment_id": self.equipment.id, + "picking_type_id": self.env.ref("stock.picking_type_in").id, + } + ) + # Should allow sparepart product + line = self.env["purchase.request.line"].create( + { + "request_id": purchase_request.id, + "product_id": self.product.id, + "product_uom_id": self.product.uom_id.id, + "product_qty": 1.0, + } + ) + self.assertEqual(line.product_id, self.product) + + def test_purchase_request_product_restriction_fail(self): + """Test that purchase request blocks non-sparepart products.""" + # Create another product that is NOT a sparepart + other_product = self.Product.create( + { + "name": "Other Product", + "purchase_ok": True, + "type": "consu", + "is_storable": True, + } + ) + # Create purchase request with equipment + purchase_request = self.env["purchase.request"].create( + { + "requested_by": self.env.user.id, + "equipment_id": self.equipment.id, + "picking_type_id": self.env.ref("stock.picking_type_in").id, + } + ) + # Should NOT allow non-sparepart product + with self.assertRaises(ValidationError): + self.env["purchase.request.line"].create( + { + "request_id": purchase_request.id, + "product_id": other_product.id, + "product_uom_id": other_product.uom_id.id, + "product_qty": 1.0, + } + ) + + def test_action_create_purchase_request(self): + """Test action to create purchase request for low stock.""" + # Create sparepart with low stock + self.Sparepart.create( + { + "equipment_id": self.equipment.id, + "product_id": self.product.id, + "installed_qty": 2.0, + "spare_qty": 10.0, # High spare qty, so needs reorder + } + ) + result = self.equipment.action_create_purchase_request() + # Should return action with purchase request + self.assertIn("res_id", result) + purchase_request = self.env["purchase.request"].browse(result["res_id"]) + self.assertEqual(purchase_request.equipment_id, self.equipment) + self.assertTrue(purchase_request.line_ids) diff --git a/maintenance_equipment_spareparts/views/maintenance_equipment_sparepart_views.xml b/maintenance_equipment_spareparts/views/maintenance_equipment_sparepart_views.xml new file mode 100644 index 000000000..ee5975131 --- /dev/null +++ b/maintenance_equipment_spareparts/views/maintenance_equipment_sparepart_views.xml @@ -0,0 +1,52 @@ + + + + + maintenance.equipment.sparepart.tree + maintenance.equipment.sparepart + + + + + + + + + + + + + maintenance.equipment.sparepart.form + maintenance.equipment.sparepart + +
+ + + + + + + + + + + + + + +
+
+
+ + + maintenance.equipment.sparepart.search + maintenance.equipment.sparepart + + + + + + + +
diff --git a/maintenance_equipment_spareparts/views/maintenance_equipment_views.xml b/maintenance_equipment_spareparts/views/maintenance_equipment_views.xml new file mode 100644 index 000000000..12527f93c --- /dev/null +++ b/maintenance_equipment_spareparts/views/maintenance_equipment_views.xml @@ -0,0 +1,43 @@ + + + + + equipment.form (spareparts) + maintenance.equipment + + + + + + + + + + + + + + + + + + + + + + diff --git a/maintenance_equipment_spareparts/views/maintenance_request_sparepart_consumption_views.xml b/maintenance_equipment_spareparts/views/maintenance_request_sparepart_consumption_views.xml new file mode 100644 index 000000000..020e15354 --- /dev/null +++ b/maintenance_equipment_spareparts/views/maintenance_request_sparepart_consumption_views.xml @@ -0,0 +1,52 @@ + + + + + maintenance.request.sparepart.consumption.tree + maintenance.request.sparepart.consumption + + + + + + + + + + + + + maintenance.request.sparepart.consumption.form + maintenance.request.sparepart.consumption + +
+ + + + + + + + + + + + + + + + +
+
+
+
diff --git a/maintenance_equipment_spareparts/views/maintenance_request_views.xml b/maintenance_equipment_spareparts/views/maintenance_request_views.xml new file mode 100644 index 000000000..05de6ecfc --- /dev/null +++ b/maintenance_equipment_spareparts/views/maintenance_request_views.xml @@ -0,0 +1,44 @@ + + + + + equipment.request.form (spareparts) + maintenance.request + + + + + + + + + + + + + + + +