[MIG] project_link_from_invoice: migrate to 18.0

This commit is contained in:
Stéphan Sainléger
2026-06-04 11:53:45 +02:00
parent cdf8d6c320
commit 055003c159
9 changed files with 437 additions and 0 deletions

View File

@@ -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 (<https://elabore.coop>)
### Maintainer
This module is maintained by Elabore.

View File

@@ -0,0 +1,2 @@
from . import models

View File

@@ -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,
}

View File

@@ -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)"

View File

@@ -0,0 +1,2 @@
from . import account_move

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_move_form_project_link_from_invoice_inherit" model="ir.ui.view">
<field name="name">account.move.form.project.link</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form" />
<field name="priority" eval="99" />
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<button
name="action_open_projects"
class="oe_stat_button"
icon="fa-puzzle-piece"
type="object"
invisible="project_count == 0"
>
<field name="project_count" widget="statinfo" string="Project(s)" />
</button>
</xpath>
</field>
</record>
<record id="view_invoice_tree_project_link_from_invoice_inherit" model="ir.ui.view">
<field name="name">account.invoice.list.project.link</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_invoice_tree"/>
<field name="arch" type="xml">
<xpath expr="//list/field[@name='invoice_date']" position="before">
<field name="projects_name" />
</xpath>
</field>
</record>
</odoo>