diff --git a/project_sale_link/README.rst b/project_sale_link/README.rst new file mode 100644 index 0000000000..e4c804a337 --- /dev/null +++ b/project_sale_link/README.rst @@ -0,0 +1,92 @@ +===================== +Project Sale Link +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:58bc519786cdba00abecbf70c4dfc47c91c7dd091d2b89be65d4ec57d21ed390 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fproject-lightgray.png?logo=github + :target: https://github.com/OCA/project/tree/18.0/project_sale_link + :alt: OCA/project +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/project-18-0/project-18-0-project_sale_link + :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/project&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +With this module you can access to sale orders and invoices related +to the project. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module, you need to: + +1. Belong to the "Show Full Accounting Features" and "Analytic + Accounting" groups. +2. On the lines of sale orders or invoices, indicate the analytical + account associated with the project. + +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 +------- + +* AvanzOSC +* TechnoLibre + +Contributors +------------ + +- Oihane Crucelaegui +- Ana Juaristi +- Alberto Martín Cortada +- Zina Rasoamanana +- Mathieu Benoit + +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/project `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/project_sale_link/__init__.py b/project_sale_link/__init__.py new file mode 100644 index 0000000000..bc211e699e --- /dev/null +++ b/project_sale_link/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Oihane Crucelaegui - AvanzOSC +# Copyright 2025 Mathieu Benoit - TechnoLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/project_sale_link/__manifest__.py b/project_sale_link/__manifest__.py new file mode 100644 index 0000000000..c1639662e4 --- /dev/null +++ b/project_sale_link/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Gontzal Gomez - AvanzOSC +# Copyright 2025 Mathieu Benoit - TechnoLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Project Sale Link", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "depends": ["project", "sale"], + "author": "AvanzOSC, " "TechnoLibre, " "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/project", + "category": "Project", + "data": ["views/project_project_view.xml"], + "installable": True, +} diff --git a/project_sale_link/i18n/fr_CA.po b/project_sale_link/i18n/fr_CA.po new file mode 100644 index 0000000000..09b4052384 --- /dev/null +++ b/project_sale_link/i18n/fr_CA.po @@ -0,0 +1,53 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_sale_link +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-15 03:36+0000\n" +"PO-Revision-Date: 2025-10-15 03:36+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_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_count +msgid "# Sale" +msgstr "# Vente" + +#. module: project_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_invoice_count +msgid "# Sale Invoice" +msgstr "# Facture de vente" + +#. module: project_sale_link +#: model:ir.model,name:project_sale_link.model_project_project +msgid "Project" +msgstr "Projet" + +#. module: project_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_invoice_line_total +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sale Invoice Total" +msgstr "Total de la facture de vente" + +#. module: project_sale_link +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sale Invoices" +msgstr "Factures de vente" + +#. module: project_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_line_total +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sale Total" +msgstr "Vente total" + +#. module: project_sale_link +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sales" +msgstr "Ventes" diff --git a/project_sale_link/i18n/project_sale_link.pot b/project_sale_link/i18n/project_sale_link.pot new file mode 100644 index 0000000000..d17cd4cc5e --- /dev/null +++ b/project_sale_link/i18n/project_sale_link.pot @@ -0,0 +1,53 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * project_sale_link +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-10-15 03:35+0000\n" +"PO-Revision-Date: 2025-10-15 03:35+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_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_count +msgid "# Sale" +msgstr "" + +#. module: project_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_invoice_count +msgid "# Sale Invoice" +msgstr "" + +#. module: project_sale_link +#: model:ir.model,name:project_sale_link.model_project_project +msgid "Project" +msgstr "" + +#. module: project_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_invoice_line_total +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sale Invoice Total" +msgstr "" + +#. module: project_sale_link +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sale Invoices" +msgstr "" + +#. module: project_sale_link +#: model:ir.model.fields,field_description:project_sale_link.field_project_project__sale_line_total +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sale Total" +msgstr "" + +#. module: project_sale_link +#: model_terms:ir.ui.view,arch_db:project_sale_link.project_project_view_form +msgid "Sales" +msgstr "" diff --git a/project_sale_link/models/__init__.py b/project_sale_link/models/__init__.py new file mode 100644 index 0000000000..c7c33226f1 --- /dev/null +++ b/project_sale_link/models/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Oihane Crucelaegui - AvanzOSC +# Copyright 2025 Mathieu Benoit - TechnoLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import project_project diff --git a/project_sale_link/models/project_project.py b/project_sale_link/models/project_project.py new file mode 100644 index 0000000000..a8dd3fe121 --- /dev/null +++ b/project_sale_link/models/project_project.py @@ -0,0 +1,192 @@ +# Copyright 2025 Oihane Crucelaegui - AvanzOSC +# Copyright 2025 Mathieu Benoit - TechnoLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval + + +class ProjectProject(models.Model): + _inherit = "project.project" + + sale_count = fields.Integer(compute="_compute_sale_info", string="# Sale") + sale_line_total = fields.Monetary( + compute="_compute_sale_info", + string="Sale Total", + currency_field="currency_id", + ) + sale_invoice_count = fields.Integer( + compute="_compute_sale_invoice_info", string="# Sale Invoice" + ) + sale_invoice_line_total = fields.Monetary( + compute="_compute_sale_invoice_info", + string="Sale Invoice Total", + currency_field="currency_id", + ) + currency_id = fields.Many2one( + related="company_id.currency_id", + string="Currency", + ) + + def _domain_sale_order(self): + query = self.env["sale.order.line"]._search( + [ + ("order_id.state", "!=", "cancel"), + ] + ) + # check if analytic_distribution contains id of analytic account + query.add_where( + "sale_order_line.analytic_distribution ?| array[%s]", + [str(project.account_id.id) for project in self], + ) + + query.order = None + query_string, query_param = query.select( + "sale_order_line.order_id as order_id", + ) + self._cr.execute(query_string, query_param) + sale_lines_ids = list( + set([int(record.get("order_id")) for record in self._cr.dictfetchall()]) + ) + domain = [("id", "in", sale_lines_ids)] + return domain + + def _domain_sale_order_line(self): + query = self.env["sale.order.line"]._search( + [ + ("order_id.state", "!=", "cancel"), + ] + ) + # check if analytic_distribution contains id of analytic account + query.add_where( + "sale_order_line.analytic_distribution ?| array[%s]", + [str(project.account_id.id) for project in self], + ) + + query.order = None + query_string, query_param = query.select( + "sale_order_line.id as id", + ) + self._cr.execute(query_string, query_param) + sale_lines_ids = [int(record.get("id")) for record in self._cr.dictfetchall()] + domain = [("id", "in", sale_lines_ids)] + return domain + + def _domain_sale_invoice(self): + query = self.env["account.move.line"]._search( + [ + ("move_id.state", "!=", "cancel"), + ("move_id.move_type", "=", "out_invoice"), + ] + ) + # check if analytic_distribution contains id of analytic account + ids = [str(p.account_id.id) for p in self if p.account_id] + query.add_where( + "account_move_line.analytic_distribution ? ANY (%s::text[])", + (ids,), + ) + query.order = None + query_string, query_param = query.select( + "DISTINCT(account_move_line.move_id) as move_id", + ) + self._cr.execute(query_string, query_param) + sale_invoice_ids = [ + int(record.get("move_id")) for record in self._cr.dictfetchall() + ] + domain = [("id", "in", sale_invoice_ids)] + return domain + + def _domain_sale_invoice_line(self): + query = self.env["account.move.line"]._search( + [ + ("move_id.state", "!=", "cancel"), + ("move_id.move_type", "=", "out_invoice"), + ] + ) + # check if analytic_distribution contains id of analytic account + ids = [str(p.account_id.id) for p in self if p.account_id] + query.add_where( + "account_move_line.analytic_distribution ? ANY (%s::text[])", + (ids,), + ) + query.order = None + query_string, query_param = query.select( + "account_move_line.id as id", + ) + self._cr.execute(query_string, query_param) + sale_invoice_lines_ids = [ + int(record.get("id")) for record in self._cr.dictfetchall() + ] + domain = [("id", "in", sale_invoice_lines_ids)] + return domain + + def _compute_sale_info(self): + for project in self: + groups = self.env["sale.order.line"].read_group( + project._domain_sale_order_line(), + ["price_subtotal"], + ["order_id"], + ) + sale_line_total = 0 + for group in groups: + sale_line_total += group["price_subtotal"] + project.sale_count = len(groups) + project.sale_line_total = sale_line_total + + def _compute_sale_invoice_info(self): + for project in self: + groups = self.env["account.move.line"].read_group( + project._domain_sale_invoice_line(), + ["price_subtotal"], + ["move_id"], + ) + sale_invoice_line_total = 0 + for group in groups: + sale_invoice_line_total += group["price_subtotal"] + project.sale_invoice_count = len(groups) + project.sale_invoice_line_total = sale_invoice_line_total + + def button_open_sale_order(self): + self.ensure_one() + return { + "name": self.env._("Sale Order"), + "domain": self._domain_sale_order(), + "type": "ir.actions.act_window", + "view_mode": "list,form", + "res_model": "sale.order", + } + + def button_open_sale_order_line(self): + self.ensure_one() + return { + "name": self.env._("Sale Order Lines"), + "domain": self._domain_sale_order_line(), + "type": "ir.actions.act_window", + "view_mode": "list,form", + "res_model": "sale.order.line", + } + + def button_open_sale_invoice(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "account.action_move_out_invoice_type" + ) + domain = expression.AND( + [ + safe_eval(action.get("domain", "[]")), + self._domain_sale_invoice(), + ] + ) + action.update({"domain": domain}) + return action + + def button_open_sale_invoice_line(self): + self.ensure_one() + return { + "name": self.env._("Sale Invoice Lines"), + "domain": self._domain_sale_invoice_line(), + "type": "ir.actions.act_window", + "view_mode": "list,form", + "res_model": "account.move.line", + } diff --git a/project_sale_link/pyproject.toml b/project_sale_link/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/project_sale_link/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/project_sale_link/readme/CONTRIBUTORS.md b/project_sale_link/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..0201f1df17 --- /dev/null +++ b/project_sale_link/readme/CONTRIBUTORS.md @@ -0,0 +1,6 @@ +- Oihane Crucelaegui \<\> +- Ana Juaristi \<\> +- Alberto Martín Cortada \<\> +- Zina Rasoamanana \<\> +- Mathieu Benoit \<\> + diff --git a/project_sale_link/readme/CREDITS.md b/project_sale_link/readme/CREDITS.md new file mode 100644 index 0000000000..e2dd5f9908 --- /dev/null +++ b/project_sale_link/readme/CREDITS.md @@ -0,0 +1 @@ +This module comes from [project_purchase_link](../../project_purchase_link). diff --git a/project_sale_link/readme/DESCRIPTION.md b/project_sale_link/readme/DESCRIPTION.md new file mode 100644 index 0000000000..6073e1eaf1 --- /dev/null +++ b/project_sale_link/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +With this module you can access to sale orders and invoices related +to the project. diff --git a/project_sale_link/readme/USAGE.md b/project_sale_link/readme/USAGE.md new file mode 100644 index 0000000000..d7f2142d6d --- /dev/null +++ b/project_sale_link/readme/USAGE.md @@ -0,0 +1,6 @@ +To use this module, you need to: + +1. Belong to the "Show Full Accounting Features" and "Analytic + Accounting" groups. +2. On the lines of sale orders or invoices, indicate the analytical + account associated with the project. diff --git a/project_sale_link/static/description/icon.png b/project_sale_link/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/project_sale_link/static/description/icon.png differ diff --git a/project_sale_link/static/description/index.html b/project_sale_link/static/description/index.html new file mode 100644 index 0000000000..859cbc29fd --- /dev/null +++ b/project_sale_link/static/description/index.html @@ -0,0 +1,438 @@ + + + + + +Project Sale Link + + + + + + diff --git a/project_sale_link/tests/__init__.py b/project_sale_link/tests/__init__.py new file mode 100644 index 0000000000..56b3ad4778 --- /dev/null +++ b/project_sale_link/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Oihane Crucelaegui - AvanzOSC +# Copyright 2025 Mathieu Benoit - TechnoLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_project_sale_link diff --git a/project_sale_link/tests/test_project_sale_link.py b/project_sale_link/tests/test_project_sale_link.py new file mode 100644 index 0000000000..362d050357 --- /dev/null +++ b/project_sale_link/tests/test_project_sale_link.py @@ -0,0 +1,109 @@ +# Copyright 2025 Oihane Crucelaegui - AvanzOSC +# Copyright 2025 Mathieu Benoit - TechnoLibre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command +from odoo.osv import expression +from odoo.tools.safe_eval import safe_eval + +from odoo.addons.base.tests.common import BaseCommon + + +class TestProjectSaleUtilities(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.project_model = cls.env["project.project"] + cls.project = cls.project_model.create({"name": "Test Project"}) + cls.sale_model = cls.env["sale.order"] + cls.partner = cls.env.ref("base.res_partner_2") + cls.product = cls.env["product.product"].create( + { + "name": "Product Product 4", + "standard_price": 500.0, + "list_price": 750.0, + "type": "consu", + "categ_id": cls.env.ref("product.product_category_all").id, + } + ) + cls.sale = cls.sale_model.create( + { + "partner_id": cls.partner.id, + } + ) + cls.invoice_model = cls.env["account.move"] + cls.invoice_line_model = cls.env["account.move.line"] + + def test_project_sale(self): + self.assertTrue(self.sale) + self.sale.write({"invoice_status": "to invoice"}) + self.assertFalse(self.project.sale_count) + self.assertFalse(self.project.sale_line_total) + self.assertFalse(self.project.sale_invoice_count) + self.assertFalse(self.project.sale_invoice_line_total) + self.sale.write( + { + "order_line": [ + Command.create( + { + "name": "Test line", + "analytic_distribution": {self.project.account_id.id: 100}, + "price_unit": 50, + "product_uom_qty": 4, + "qty_delivered": 4, + "product_uom": self.product.uom_id.id, + "product_id": self.product.id, + } + ) + ] + } + ) + + self.env.invalidate_all() + + self.assertEqual(self.project.sale_count, 1) + self.assertEqual(self.project.sale_line_total, 200) + self.assertFalse(self.project.sale_invoice_count) + self.assertFalse(self.project.sale_invoice_line_total) + self.sale.action_confirm() + invoice = self.invoice_model.create( + { + "partner_id": self.sale.partner_id.id, + "move_type": "out_invoice", + } + ) + for line in self.sale.order_line: + categ_id = line.product_id.categ_id + account_id = categ_id.property_account_expense_categ_id.id + vals = { + "move_id": invoice.id, + "name": line.name, + "account_id": account_id, + "analytic_distribution": line.analytic_distribution, + } + self.invoice_line_model.create(vals) + self.env.invalidate_all() + self.assertEqual(self.project.sale_invoice_count, 1) + + sale_domain = self.project._domain_sale_order_line() + + lines = self.env["sale.order.line"].search(sale_domain) + order_domain = [("id", "in", lines.mapped("order_id").ids)] + sale_dict = self.project.button_open_sale_order() + self.assertEqual(sale_dict.get("domain"), order_domain) + sale_line_dict = self.project.button_open_sale_order_line() + self.assertEqual(sale_line_dict.get("domain"), sale_domain) + + action = self.env.ref("account.action_move_out_invoice_type") + invoice_domain = expression.AND( + [safe_eval(action.domain or "[]"), self.project._domain_sale_invoice()] + ) # only one test invoice (line) + + invoice_dict = self.project.button_open_sale_invoice() + self.assertEqual(invoice_dict.get("domain"), invoice_domain) + + invoice_line_domain = self.project._domain_sale_invoice_line() + + invoice_line_dict = self.project.button_open_sale_invoice_line() + self.assertEqual(invoice_line_dict.get("domain"), invoice_line_domain) diff --git a/project_sale_link/views/project_project_view.xml b/project_sale_link/views/project_project_view.xml new file mode 100644 index 0000000000..abb35f4004 --- /dev/null +++ b/project_sale_link/views/project_project_view.xml @@ -0,0 +1,55 @@ + + + + project.project + + +
+ + + + +
+
+
+