diff --git a/project_link_from_invoice/README.md b/project_link_from_invoice/README.md new file mode 100644 index 0000000..eabf03e --- /dev/null +++ b/project_link_from_invoice/README.md @@ -0,0 +1,60 @@ +# Project Link From Invoice + +![License: AGPL-3](https://img.shields.io/badge/licence-AGPL--3-blue.svg) + +Easily access your projects directly from invoices: + +- Displays project name(s) in the invoice list view. +- Adds a smart button on invoice form view to open the related project(s). + +## Installation + +This module depends on: + +- `project` (Odoo core) +- `account` (Odoo core) +- `sale_project` (Odoo core) + +Use Odoo's normal procedure to install add-ons. + +## Configuration + +No specific configuration is required. + +## Usage + +Open an invoice that is linked to a sale order. If the sale order has tasks +in a project, a "Project(s)" button appears in the header of the invoice form. +Clicking it opens the related project(s) in form or list view. + +The invoice list view also displays the project name(s) in a dedicated column. + +## Known issues / Roadmap + +None yet. + +## Bug Tracker + +Bugs are tracked on [our issues website](https://github.com/elabore-coop/project-tools/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 + +- Elabore: [Icon](https://elabore.coop/web/image/res.company/1/logo) + +### Contributors + +- Clément Thomas + +### Funders + +The development of this module has been financially supported by: + +- Elabore () + +### Maintainer + +This module is maintained by Elabore. diff --git a/project_link_from_invoice/__init__.py b/project_link_from_invoice/__init__.py new file mode 100644 index 0000000..a9e3372 --- /dev/null +++ b/project_link_from_invoice/__init__.py @@ -0,0 +1,2 @@ + +from . import models diff --git a/project_link_from_invoice/__manifest__.py b/project_link_from_invoice/__manifest__.py new file mode 100644 index 0000000..dfc3166 --- /dev/null +++ b/project_link_from_invoice/__manifest__.py @@ -0,0 +1,34 @@ +# Copyright 2022 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "project_link_from_invoice", + "version": "18.0.1.0.0", + "author": "Elabore", + "website": "https://git.elabore.coop/elabore/project-tools", + "maintainer": "Clément Thomas", + "license": "AGPL-3", + "category": "Tools", + "summary": "Add link from invoice to project", + # any module necessary for this one to work correctly + "depends": [ + "base", + "project", + "account", + "sale_project", + ], + "external_dependencies": { + "python": [], + }, + # always loaded + "data": [ + "views/account_move_view.xml", + ], + # only loaded in demonstration mode + "demo": [], + "installable": True, + # Install this module automatically if all dependency have been previously + # and independently installed. Used for synergetic or glue modules. + "auto_install": False, + "application": False, +} diff --git a/project_link_from_invoice/i18n/fr.po b/project_link_from_invoice/i18n/fr.po new file mode 100644 index 0000000..5d2320b --- /dev/null +++ b/project_link_from_invoice/i18n/fr.po @@ -0,0 +1,58 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_link_from_invoice +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-04-27 12:47+0000\n" +"PO-Revision-Date: 2023-04-27 12:47+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: project_link_from_invoice +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_move__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: project_link_from_invoice +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_move__id +msgid "ID" +msgstr "" + +#. module: project_link_from_invoice +#: model:ir.model,name:project_link_from_invoice.model_account_move +msgid "Journal Entry" +msgstr "Pièce comptable" + +#. module: project_link_from_invoice +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_move____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: project_link_from_invoice +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_bank_statement_line__project_ids +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_move__project_ids +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_payment__project_ids +msgid "Project" +msgstr "Projet" + +#. module: project_link_from_invoice +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_bank_statement_line__project_count +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_move__project_count +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_payment__project_count +msgid "Project Count" +msgstr "Numbre de projets" + +#. module: project_link_from_invoice +#: model_terms:ir.ui.view,arch_db:project_link_from_invoice.view_move_form_project_link_from_invoice_inherit +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_bank_statement_line__projects_name +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_move__projects_name +#: model:ir.model.fields,field_description:project_link_from_invoice.field_account_payment__projects_name +msgid "Project(s)" +msgstr "Projet(s)" diff --git a/project_link_from_invoice/models/__init__.py b/project_link_from_invoice/models/__init__.py new file mode 100644 index 0000000..31ac2eb --- /dev/null +++ b/project_link_from_invoice/models/__init__.py @@ -0,0 +1,2 @@ + +from . import account_move \ No newline at end of file diff --git a/project_link_from_invoice/models/account_move.py b/project_link_from_invoice/models/account_move.py new file mode 100644 index 0000000..fc1761e --- /dev/null +++ b/project_link_from_invoice/models/account_move.py @@ -0,0 +1,49 @@ +# Copyright 2022 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + project_ids = fields.Many2many( + "project.project", string="Projects", compute="_get_related_project_ids" + ) + project_count = fields.Integer("Project Count", compute="_get_related_project_ids") + projects_name = fields.Char("Project(s)", compute="_get_related_project_ids") + + def action_open_projects(self): + """Open related projects, in form or list view depending on project numbers.""" + project_ids = self.project_ids.ids + action = self.env["ir.actions.actions"]._for_xml_id( + "project.open_view_project_all" + ) + + if self.project_count == 1: + action["res_id"] = project_ids[0] + action["views"] = [[False, "form"]] + else: + action["views"] = [[False, "list"], [False, "form"]] + + action["domain"] = [("id", "in", project_ids)] + + del action["target"] # to display breadcrumbs + + return action + + @api.depends("line_ids.sale_line_ids") + def _get_related_project_ids(self): + for move in self: + projects = self.env["project.task"].search( + [ + ( + "sale_order_id", + "in", + move.line_ids.sale_line_ids.order_id.ids, + ) + ] + ).project_id + move.project_ids = projects.ids + move.projects_name = " ; ".join([p.name for p in projects]) + move.project_count = len(projects) diff --git a/project_link_from_invoice/tests/__init__.py b/project_link_from_invoice/tests/__init__.py new file mode 100644 index 0000000..5fdb980 --- /dev/null +++ b/project_link_from_invoice/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2022 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_account_move diff --git a/project_link_from_invoice/tests/test_account_move.py b/project_link_from_invoice/tests/test_account_move.py new file mode 100644 index 0000000..25d0e74 --- /dev/null +++ b/project_link_from_invoice/tests/test_account_move.py @@ -0,0 +1,193 @@ +# Copyright 2022 Stéphan Sainléger (Elabore) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.tests import tagged +from odoo.addons.sale_project.tests.common import TestSaleProjectCommon + + +@tagged("post_install", "-at_install") +class TestProjectLinkFromInvoice(TestSaleProjectCommon): + """Tests for the project_link_from_invoice module. + + Verifies that invoices correctly expose related project(s) via + the computed fields project_ids, project_count and projects_name. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + + # Service product that creates a task in a new project on SO confirmation + cls.service_product = cls.env["product.product"].create( + { + "name": "Service with Project", + "type": "service", + "invoice_policy": "order", + "service_tracking": "task_in_project", + } + ) + + # Partner used for orders and invoices + cls.partner = cls.env["res.partner"].create({"name": "Test Partner"}) + + def _confirm_sale_order(self, product, qty=1): + """Create and confirm a sale order with one service line.""" + so = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": product.id, + "product_uom_qty": qty, + } + ) + ], + } + ) + so.action_confirm() + return so + + def _invoice_sale_order(self, sale_order): + """Create and post an invoice from a confirmed sale order.""" + invoice_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context( + active_model="sale.order", + active_ids=sale_order.ids, + active_id=sale_order.id, + ) + .create({"advance_payment_method": "delivered"}) + ) + invoices = invoice_wizard._create_invoices(sale_order) + invoices.action_post() + return invoices + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + def test_no_project_on_unrelated_invoice(self): + """An invoice not linked to any sale order has no related project.""" + invoice = self.env["account.move"].create( + { + "move_type": "out_invoice", + "partner_id": self.partner.id, + "invoice_line_ids": [ + Command.create( + { + "name": "Manual line", + "quantity": 1, + "price_unit": 100.0, + } + ) + ], + } + ) + self.assertEqual(invoice.project_count, 0) + self.assertFalse(invoice.project_ids) + self.assertEqual(invoice.projects_name, "") + + def test_single_project_linked_to_invoice(self): + """An invoice from a SO that created a task has one related project.""" + so = self._confirm_sale_order(self.service_product) + # The SO must have generated a task in a project + task = so.tasks_ids[:1] + self.assertTrue(task, "Sale order confirmation should have created a task") + project = task.project_id + self.assertTrue(project, "Task should belong to a project") + + invoice = self._invoice_sale_order(so) + self.assertEqual(len(invoice), 1) + self.assertEqual(invoice.project_count, 1) + self.assertIn(project, invoice.project_ids) + self.assertIn(project.name, invoice.projects_name) + + def test_multiple_projects_linked_to_invoice(self): + """An invoice from two SOs with different projects shows both projects.""" + so1 = self._confirm_sale_order(self.service_product) + so2 = self._confirm_sale_order(self.service_product) + + project1 = so1.tasks_ids[:1].project_id + project2 = so2.tasks_ids[:1].project_id + self.assertTrue(project1 and project2) + # Ensure the two SOs created different projects + self.assertNotEqual(project1, project2) + + # Create a single invoice covering both sale orders + invoice_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context( + active_model="sale.order", + active_ids=(so1 + so2).ids, + ) + .create({"advance_payment_method": "delivered"}) + ) + invoices = invoice_wizard._create_invoices(so1 + so2) + invoices.action_post() + + # Find the invoice that covers both SO lines + combined_invoice = invoices[:1] + self.assertGreaterEqual(combined_invoice.project_count, 2) + self.assertIn(project1, combined_invoice.project_ids) + self.assertIn(project2, combined_invoice.project_ids) + + def test_action_open_projects_single(self): + """action_open_projects returns a form view when there is one project.""" + so = self._confirm_sale_order(self.service_product) + invoice = self._invoice_sale_order(so) + self.assertEqual(invoice.project_count, 1) + + action = invoice.action_open_projects() + self.assertEqual(action["res_id"], invoice.project_ids.id) + view_types = [v[1] for v in action["views"]] + self.assertIn("form", view_types) + self.assertNotIn("list", view_types) + + def test_action_open_projects_multiple(self): + """action_open_projects returns a list view when there are multiple projects.""" + so1 = self._confirm_sale_order(self.service_product) + so2 = self._confirm_sale_order(self.service_product) + + invoice_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context( + active_model="sale.order", + active_ids=(so1 + so2).ids, + ) + .create({"advance_payment_method": "delivered"}) + ) + invoices = invoice_wizard._create_invoices(so1 + so2) + invoices.action_post() + + combined_invoice = invoices[:1] + if combined_invoice.project_count < 2: + self.skipTest("Combined invoice does not span multiple projects") + + action = combined_invoice.action_open_projects() + view_types = [v[1] for v in action["views"]] + self.assertIn("list", view_types) + self.assertIn("form", view_types) + self.assertNotIn("target", action) + + def test_projects_name_separator(self): + """projects_name joins multiple project names with ' ; '.""" + so1 = self._confirm_sale_order(self.service_product) + so2 = self._confirm_sale_order(self.service_product) + + invoice_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context( + active_model="sale.order", + active_ids=(so1 + so2).ids, + ) + .create({"advance_payment_method": "delivered"}) + ) + invoices = invoice_wizard._create_invoices(so1 + so2) + invoices.action_post() + + combined_invoice = invoices[:1] + if combined_invoice.project_count >= 2: + self.assertIn(" ; ", combined_invoice.projects_name) diff --git a/project_link_from_invoice/views/account_move_view.xml b/project_link_from_invoice/views/account_move_view.xml new file mode 100644 index 0000000..45ff71f --- /dev/null +++ b/project_link_from_invoice/views/account_move_view.xml @@ -0,0 +1,35 @@ + + + + account.move.form.project.link + account.move + + + + + + + + + + + account.invoice.list.project.link + account.move + + + + + + + + + + \ No newline at end of file