diff --git a/fieldservice_equipment_location_history/README.rst b/fieldservice_equipment_location_history/README.rst new file mode 100644 index 0000000000..697786e585 --- /dev/null +++ b/fieldservice_equipment_location_history/README.rst @@ -0,0 +1,96 @@ +========================================== +Field Service - Equipment Location History +========================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1bb20ba410df55279f71114f0ac897195240e0103bc25429fc3a3b2cf7de5908 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Ffield--service-lightgray.png?logo=github + :target: https://github.com/OCA/field-service/tree/18.0/fieldservice_equipment_location_history + :alt: OCA/field-service +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/field-service-18-0/field-service-18-0-fieldservice_equipment_location_history + :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/field-service&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Track and execute transfers of field service equipments between stock +locations. + +Each transfer record creates and validates a real internal picking +moving the equipment's serial number, so stock quants and traceability +stay consistent. When the target stock location is linked to an FSM +location (or matches one by name), the equipment's FSM location is +updated as well. + +A **Transfer Stock** button on the equipment form pre-fills the transfer +with the equipment's current stock location, and a **Location History** +tab lists past movements with their pickings. + +**Table of contents** + +.. contents:: + :local: + +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 +------- + +* Pop Solutions + +Contributors +------------ + +- Rafnix Guzman +- Marcos Mendez + +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. + +.. |maintainer-marcos-mendez| image:: https://github.com/marcos-mendez.png?size=40px + :target: https://github.com/marcos-mendez + :alt: marcos-mendez + +Current `maintainer `__: + +|maintainer-marcos-mendez| + +This module is part of the `OCA/field-service `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fieldservice_equipment_location_history/__init__.py b/fieldservice_equipment_location_history/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/fieldservice_equipment_location_history/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fieldservice_equipment_location_history/__manifest__.py b/fieldservice_equipment_location_history/__manifest__.py new file mode 100644 index 0000000000..109592070a --- /dev/null +++ b/fieldservice_equipment_location_history/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright (C) 2022 Rafnix Guzman +# Copyright (C) 2026 Pop Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Field Service - Equipment Location History", + "summary": "Track and execute equipment transfers between stock locations", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "website": "https://github.com/OCA/field-service", + "category": "Field Service", + "author": "Pop Solutions, Odoo Community Association (OCA)", + "maintainers": ["marcos-mendez"], + "depends": ["fieldservice_equipment_stock"], + "data": [ + "security/ir.model.access.csv", + "views/fsm_equipment_location_history_views.xml", + "views/fsm_equipment_views.xml", + ], +} diff --git a/fieldservice_equipment_location_history/models/__init__.py b/fieldservice_equipment_location_history/models/__init__.py new file mode 100644 index 0000000000..2216bbcdfa --- /dev/null +++ b/fieldservice_equipment_location_history/models/__init__.py @@ -0,0 +1,2 @@ +from . import fsm_equipment_location_history +from . import fsm_equipment diff --git a/fieldservice_equipment_location_history/models/fsm_equipment.py b/fieldservice_equipment_location_history/models/fsm_equipment.py new file mode 100644 index 0000000000..223c6a2658 --- /dev/null +++ b/fieldservice_equipment_location_history/models/fsm_equipment.py @@ -0,0 +1,27 @@ +# Copyright (C) 2022 Rafnix Guzman +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class FsmEquipment(models.Model): + _inherit = "fsm.equipment" + + location_history_ids = fields.One2many( + "fsm.equipment.location.history", "equipment_id", string="Location History" + ) + + def action_create_location_history(self): + self.ensure_one() + return { + "name": "Transfer Equipment", + "type": "ir.actions.act_window", + "view_mode": "form", + "res_model": "fsm.equipment.location.history", + "context": { + "default_equipment_id": self.id, + "default_from_location_id": self.current_stock_location_id.id, + "default_date": fields.Datetime.now(), + }, + "target": "new", + } diff --git a/fieldservice_equipment_location_history/models/fsm_equipment_location_history.py b/fieldservice_equipment_location_history/models/fsm_equipment_location_history.py new file mode 100644 index 0000000000..aafc485df5 --- /dev/null +++ b/fieldservice_equipment_location_history/models/fsm_equipment_location_history.py @@ -0,0 +1,111 @@ +# Copyright (C) 2022 Rafnix Guzman +# Copyright (C) 2026 Pop Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class FsmEquipmentLocationHistory(models.Model): + _name = "fsm.equipment.location.history" + _description = "Equipment Stock Location History" + _order = "date desc, id desc" + + from_location_id = fields.Many2one( + "stock.location", string="From Location", required=True + ) + to_location_id = fields.Many2one( + "stock.location", string="To Location", required=True + ) + equipment_id = fields.Many2one("fsm.equipment", string="Equipment", required=True) + date = fields.Datetime(required=True, default=fields.Datetime.now) + type_selection = fields.Selection( + [("inbound", "Inbound"), ("outbound", "Outbound")], + string="Type", + required=True, + default="inbound", + ) + picking_id = fields.Many2one( + "stock.picking", string="Transfer", readonly=True, copy=False + ) + + @api.model_create_multi + def create(self, vals_list): + histories = super().create(vals_list) + for history in histories: + history._execute_transfer() + return histories + + def _execute_transfer(self): + """Move the equipment's serial to the target location with a real + internal transfer, so quants and traceability stay consistent.""" + self.ensure_one() + equipment = self.equipment_id + if not equipment.lot_id or not equipment.product_id: + raise UserError( + _( + "Equipment %s has no product/serial number: " + "a stock transfer cannot be created." + ) + % equipment.display_name + ) + picking_type = self.env["stock.picking.type"].search( + [("code", "=", "internal")], limit=1 + ) + if not picking_type: + raise UserError( + _( + "No internal transfer operation type found. " + "Enable Storage Locations in Inventory settings." + ) + ) + picking = self.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "location_id": self.from_location_id.id, + "location_dest_id": self.to_location_id.id, + "scheduled_date": self.date, + } + ) + move = self.env["stock.move"].create( + { + "name": equipment.display_name, + "product_id": equipment.product_id.id, + "product_uom": equipment.product_id.uom_id.id, + "product_uom_qty": 1.0, + "location_id": self.from_location_id.id, + "location_dest_id": self.to_location_id.id, + "picking_id": picking.id, + "date": self.date, + } + ) + picking.action_confirm() + picking.action_assign() + move_line = move.move_line_ids[:1] + if not move_line: + move_line = self.env["stock.move.line"].create( + { + "move_id": move.id, + "picking_id": picking.id, + "product_id": equipment.product_id.id, + "location_id": self.from_location_id.id, + "location_dest_id": self.to_location_id.id, + } + ) + move_line.write({"lot_id": equipment.lot_id.id, "quantity": 1.0}) + move.picked = True + picking.button_validate() + self.picking_id = picking.id + self._update_equipment_fsm_location() + + def _update_equipment_fsm_location(self): + """Point the equipment to the FSM location matching the target stock + location, when one exists (by inventory location, then by name).""" + self.ensure_one() + fsm_location = self.env["fsm.location"].search( + [("inventory_location_id", "=", self.to_location_id.id)], limit=1 + ) or self.env["fsm.location"].search( + [("name", "=", self.to_location_id.name)], limit=1 + ) + if fsm_location: + self.equipment_id.location_id = fsm_location.id diff --git a/fieldservice_equipment_location_history/pyproject.toml b/fieldservice_equipment_location_history/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/fieldservice_equipment_location_history/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fieldservice_equipment_location_history/readme/CONTRIBUTORS.md b/fieldservice_equipment_location_history/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..c2dba6666f --- /dev/null +++ b/fieldservice_equipment_location_history/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Rafnix Guzman \ +- Marcos Mendez \ diff --git a/fieldservice_equipment_location_history/readme/DESCRIPTION.md b/fieldservice_equipment_location_history/readme/DESCRIPTION.md new file mode 100644 index 0000000000..88682aa455 --- /dev/null +++ b/fieldservice_equipment_location_history/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +Track and execute transfers of field service equipments between stock +locations. + +Each transfer record creates and validates a real internal picking moving the +equipment's serial number, so stock quants and traceability stay consistent. +When the target stock location is linked to an FSM location (or matches one by +name), the equipment's FSM location is updated as well. + +A **Transfer Stock** button on the equipment form pre-fills the transfer with +the equipment's current stock location, and a **Location History** tab lists +past movements with their pickings. diff --git a/fieldservice_equipment_location_history/security/ir.model.access.csv b/fieldservice_equipment_location_history/security/ir.model.access.csv new file mode 100644 index 0000000000..03bd448598 --- /dev/null +++ b/fieldservice_equipment_location_history/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fsm_equipment_location_history_user,fsm.equipment.location.history fsm user,model_fsm_equipment_location_history,fieldservice.group_fsm_user,1,1,1,0 +access_fsm_equipment_location_history_manager,fsm.equipment.location.history fsm manager,model_fsm_equipment_location_history,fieldservice.group_fsm_manager,1,1,1,1 diff --git a/fieldservice_equipment_location_history/static/description/index.html b/fieldservice_equipment_location_history/static/description/index.html new file mode 100644 index 0000000000..ae83c63416 --- /dev/null +++ b/fieldservice_equipment_location_history/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Field Service - Equipment Location History + + + +
+

Field Service - Equipment Location History

+ + +

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

+

Track and execute transfers of field service equipments between stock +locations.

+

Each transfer record creates and validates a real internal picking +moving the equipment’s serial number, so stock quants and traceability +stay consistent. When the target stock location is linked to an FSM +location (or matches one by name), the equipment’s FSM location is +updated as well.

+

A Transfer Stock button on the equipment form pre-fills the transfer +with the equipment’s current stock location, and a Location History +tab lists past movements with their pickings.

+

Table of contents

+ +
+

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

+
    +
  • Pop Solutions
  • +
+
+
+

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.

+

Current maintainer:

+

marcos-mendez

+

This module is part of the OCA/field-service project on GitHub.

+

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

+
+
+
+ + diff --git a/fieldservice_equipment_location_history/tests/__init__.py b/fieldservice_equipment_location_history/tests/__init__.py new file mode 100644 index 0000000000..5fc3f36f5b --- /dev/null +++ b/fieldservice_equipment_location_history/tests/__init__.py @@ -0,0 +1 @@ +from . import test_location_history diff --git a/fieldservice_equipment_location_history/tests/test_location_history.py b/fieldservice_equipment_location_history/tests/test_location_history.py new file mode 100644 index 0000000000..923ea3c284 --- /dev/null +++ b/fieldservice_equipment_location_history/tests/test_location_history.py @@ -0,0 +1,134 @@ +# Copyright (C) 2026 Pop Solutions +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestEquipmentLocationHistory(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["res.config.settings"].create( + {"group_stock_multi_locations": True} + ).execute() + cls.warehouse = cls.env["stock.warehouse"].search([], limit=1) + cls.loc_a = cls.warehouse.lot_stock_id + cls.loc_b = cls.env["stock.location"].create( + {"name": "Bench B", "location_id": cls.loc_a.location_id.id} + ) + cls.product = cls.env["product.product"].create( + { + "name": "Tracked Device", + "is_storable": True, + "tracking": "serial", + } + ) + cls.lot = cls.env["stock.lot"].create( + {"name": "SN-0001", "product_id": cls.product.id} + ) + cls.env["stock.quant"]._update_available_quantity( + cls.product, cls.loc_a, 1.0, lot_id=cls.lot + ) + cls.equipment = cls.env["fsm.equipment"].create( + { + "name": "Device SN-0001", + "product_id": cls.product.id, + "lot_id": cls.lot.id, + } + ) + + def test_transfer_moves_serial(self): + history = self.env["fsm.equipment.location.history"].create( + { + "equipment_id": self.equipment.id, + "from_location_id": self.loc_a.id, + "to_location_id": self.loc_b.id, + "type_selection": "inbound", + } + ) + self.assertEqual(history.picking_id.state, "done") + quant = self.env["stock.quant"].search( + [ + ("lot_id", "=", self.lot.id), + ("location_id", "=", self.loc_b.id), + ("quantity", ">", 0), + ] + ) + self.assertTrue(quant, "serial must be at destination after transfer") + self.assertIn(history, self.equipment.location_history_ids) + + def test_transfer_updates_fsm_location(self): + partner = self.env["res.partner"].create({"name": "FSM Loc Partner"}) + fsm_location = self.env["fsm.location"].create( + { + "name": "Bench B", + "owner_id": partner.id, + "inventory_location_id": self.loc_b.id, + } + ) + self.env["fsm.equipment.location.history"].create( + { + "equipment_id": self.equipment.id, + "from_location_id": self.loc_a.id, + "to_location_id": self.loc_b.id, + } + ) + self.assertEqual(self.equipment.location_id, fsm_location) + + def test_transfer_without_serial_raises(self): + bare_equipment = self.env["fsm.equipment"].create({"name": "No Serial"}) + with self.assertRaises(UserError): + self.env["fsm.equipment.location.history"].create( + { + "equipment_id": bare_equipment.id, + "from_location_id": self.loc_a.id, + "to_location_id": self.loc_b.id, + } + ) + + def test_transfer_without_internal_picking_type_raises(self): + self.env["stock.picking.type"].search( + [("code", "=", "internal")] + ).active = False + with self.assertRaises(UserError): + self.env["fsm.equipment.location.history"].create( + { + "equipment_id": self.equipment.id, + "from_location_id": self.loc_a.id, + "to_location_id": self.loc_b.id, + } + ) + + def test_transfer_without_reservable_stock_creates_move_line(self): + product = self.env["product.product"].create( + { + "name": "Unstocked Device", + "is_storable": True, + "tracking": "serial", + } + ) + lot = self.env["stock.lot"].create( + {"name": "SN-0002", "product_id": product.id} + ) + equipment = self.env["fsm.equipment"].create( + { + "name": "Device SN-0002", + "product_id": product.id, + "lot_id": lot.id, + } + ) + history = self.env["fsm.equipment.location.history"].create( + { + "equipment_id": equipment.id, + "from_location_id": self.loc_a.id, + "to_location_id": self.loc_b.id, + } + ) + self.assertEqual(history.picking_id.state, "done") + self.assertEqual(history.picking_id.move_line_ids.lot_id, lot) + + def test_equipment_transfer_button(self): + action = self.equipment.action_create_location_history() + self.assertEqual(action["res_model"], "fsm.equipment.location.history") + self.assertEqual(action["context"]["default_equipment_id"], self.equipment.id) diff --git a/fieldservice_equipment_location_history/views/fsm_equipment_location_history_views.xml b/fieldservice_equipment_location_history/views/fsm_equipment_location_history_views.xml new file mode 100644 index 0000000000..ca4750d08c --- /dev/null +++ b/fieldservice_equipment_location_history/views/fsm_equipment_location_history_views.xml @@ -0,0 +1,55 @@ + + + + fsm.equipment.location.history.list + fsm.equipment.location.history + + + + + + + + + + + + + fsm.equipment.location.history.form + fsm.equipment.location.history + +
+ + + + + + + + + + + + + + +
+
+
+ + Equipment Location History + fsm.equipment.location.history + list,form + +

+ Transfer an equipment between stock locations. +

+
+
+ +
diff --git a/fieldservice_equipment_location_history/views/fsm_equipment_views.xml b/fieldservice_equipment_location_history/views/fsm_equipment_views.xml new file mode 100644 index 0000000000..85978229d9 --- /dev/null +++ b/fieldservice_equipment_location_history/views/fsm_equipment_views.xml @@ -0,0 +1,32 @@ + + + + fsm.equipment.form.location.history + fsm.equipment + + + +
+
+
+ + + + + + + + + + + + +
+
+