diff --git a/account_invoice_section_picking/__init__.py b/account_invoice_section_picking/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/account_invoice_section_picking/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_invoice_section_picking/__manifest__.py b/account_invoice_section_picking/__manifest__.py new file mode 100644 index 00000000000..3fdfd30115b --- /dev/null +++ b/account_invoice_section_picking/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + "name": "Acccount Invoice Section Picking", + "version": "14.0.1.0.0", + "summary": "Extension of Acccount Invoice Section Sale Order to allow " + "grouping of invoice lines according to delivery picking.", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-invoicing", + "license": "AGPL-3", + "category": "Accounting & Finance", + "depends": ["account_invoice_section_sale_order", "stock"], +} diff --git a/account_invoice_section_picking/models/__init__.py b/account_invoice_section_picking/models/__init__.py new file mode 100644 index 00000000000..5eff3afaeb5 --- /dev/null +++ b/account_invoice_section_picking/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_move_line +from . import res_company +from . import stock_picking diff --git a/account_invoice_section_picking/models/account_move_line.py b/account_invoice_section_picking/models/account_move_line.py new file mode 100644 index 00000000000..80f174b2fff --- /dev/null +++ b/account_invoice_section_picking/models/account_move_line.py @@ -0,0 +1,48 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import models + + +class AccountMoveLine(models.Model): + + _inherit = "account.move.line" + + def _get_section_group(self): + """Group invoice lines according to uninvoiced delivery pickings""" + group = super()._get_section_group() + # If product is invoiced by delivered quantities: + # - filter on done pickings to avoid displaying backorders not yet + # processed + # - Remove pickings linked with same sale order lines if an invoice + # was created after its date_done + invoice_section_grouping = self.company_id.invoice_section_grouping + if ( + invoice_section_grouping == "delivery_picking" + and self.product_id.invoice_policy == "delivery" + ): + last_invoice_line = self.search( + [ + ("sale_line_ids", "in", self.sale_line_ids.ids), + ("parent_state", "!=", "cancel"), + ("id", "!=", self.id), + ], + order="create_date", + limit=1, + ) + pickings_already_invoiced = self.env["stock.picking"].search( + [ + ("date_done", "<=", last_invoice_line.create_date), + ("id", "in", self.sale_line_ids.mapped("order_id.picking_ids").ids), + ] + ) + group = group.filtered( + lambda p: p.state == "done" + and p.id not in pickings_already_invoiced.ids + ) + return group + + def _get_section_grouping(self): + invoice_section_grouping = self.company_id.invoice_section_grouping + if invoice_section_grouping == "delivery_picking": + return "sale_line_ids.move_ids.picking_id" + return super()._get_section_grouping() diff --git a/account_invoice_section_picking/models/res_company.py b/account_invoice_section_picking/models/res_company.py new file mode 100644 index 00000000000..5e053971029 --- /dev/null +++ b/account_invoice_section_picking/models/res_company.py @@ -0,0 +1,12 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + invoice_section_grouping = fields.Selection( + selection_add=[("delivery_picking", "Group by delivery picking")], + ondelete={"delivery_picking": "set default"}, + ) diff --git a/account_invoice_section_picking/models/stock_picking.py b/account_invoice_section_picking/models/stock_picking.py new file mode 100644 index 00000000000..e0d8d5e5ecc --- /dev/null +++ b/account_invoice_section_picking/models/stock_picking.py @@ -0,0 +1,25 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo import models +from odoo.tools.safe_eval import safe_eval, time + + +class StockPicking(models.Model): + + _inherit = "stock.picking" + + def _get_invoice_section_name(self): + """Returns the text for the section name.""" + section_names = [] + for pick in self: + naming_scheme = ( + pick.partner_id.invoice_section_name_scheme + or pick.company_id.invoice_section_name_scheme + ) + if naming_scheme: + section_names.append( + safe_eval(naming_scheme, {"object": pick, "time": time}) + ) + else: + section_names.append(pick.name) + return ", ".join(section_names) diff --git a/account_invoice_section_picking/readme/CONTRIBUTORS.rst b/account_invoice_section_picking/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..e31e2f0c4fc --- /dev/null +++ b/account_invoice_section_picking/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Akim Juillerat diff --git a/account_invoice_section_picking/readme/DESCRIPTION.rst b/account_invoice_section_picking/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..b01c3ae2173 --- /dev/null +++ b/account_invoice_section_picking/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module extends `account_invoice_section_sale_order` to allow using pickings +linked to the sale order line for the grouping invoice lines when invoicing sale +orders. diff --git a/account_invoice_section_picking/readme/ROADMAP.rst b/account_invoice_section_picking/readme/ROADMAP.rst new file mode 100644 index 00000000000..0151c42fdd6 --- /dev/null +++ b/account_invoice_section_picking/readme/ROADMAP.rst @@ -0,0 +1,10 @@ +* The selection of pickings for the section name relies on the last invoice + that was created and is linked to the sale order line. In such case, there's + no guarantee the selection is correct if the quantity is reduced on prior + invoice lines. +* Moreover, as Odoo considers the draft invoices for the computation of + `qty_invoiced` on sales order line, we couldn't base the selection of the last + invoice on another field than the `create_date` although it would have been + cleaner to rely on a `date` field, but this one is only set on the posting. + Finally, defining another field for the generation of invoices wouldn't have + helped solve these issues. diff --git a/account_invoice_section_picking/tests/__init__.py b/account_invoice_section_picking/tests/__init__.py new file mode 100644 index 00000000000..955e4115470 --- /dev/null +++ b/account_invoice_section_picking/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_account_invoice_section_picking +from . import test_account_invoice_section_picking_backorder diff --git a/account_invoice_section_picking/tests/common.py b/account_invoice_section_picking/tests/common.py new file mode 100644 index 00000000000..f136c674166 --- /dev/null +++ b/account_invoice_section_picking/tests/common.py @@ -0,0 +1,30 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +from odoo.tests.common import Form, SavepointCase + + +class TestAccountInvoiceSectionPickingCommon(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.env.company.invoice_section_grouping = "delivery_picking" + cls.partner_1 = cls.env.ref("base.res_partner_1") + cls.product_1 = cls.env.ref("product.product_delivery_01") + stock = cls.env.ref("stock.stock_location_stock") + cls.env["stock.quant"]._update_available_quantity(cls.product_1, stock, 1000) + cls.product_1.invoice_policy = "order" + cls.order_1 = cls._create_order() + cls.order_2 = cls._create_order(order_line_name=cls.product_1.name) + + @classmethod + def _create_order(cls, order_line_name=None): + order_form = Form(cls.env["sale.order"]) + order_form.partner_id = cls.partner_1 + with order_form: + with order_form.order_line.new() as line_form: + line_form.product_id = cls.product_1 + line_form.product_uom_qty = 5 + if order_line_name is not None: + line_form.name = order_line_name + return order_form.save() diff --git a/account_invoice_section_picking/tests/test_account_invoice_section_picking.py b/account_invoice_section_picking/tests/test_account_invoice_section_picking.py new file mode 100644 index 00000000000..922aba29307 --- /dev/null +++ b/account_invoice_section_picking/tests/test_account_invoice_section_picking.py @@ -0,0 +1,72 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from .common import TestAccountInvoiceSectionPickingCommon + + +class TestAccountInvoiceSectionPicking(TestAccountInvoiceSectionPickingCommon): + def test_group_by_delivery_picking(self): + self.order_1.action_confirm() + self.order_2.action_confirm() + + invoice = (self.order_1 + self.order_2)._create_invoices() + self.assertEqual(len(invoice), 1) + result = { + 10: (self.order_1.picking_ids.name, "line_section"), + 20: (self.order_1.order_line.name, False), + 30: (self.order_2.picking_ids.name, "line_section"), + 40: (self.order_2.order_line.name, False), + } + for line in invoice.invoice_line_ids.sorted("sequence"): + self.assertEqual(line.name, result[line.sequence][0]) + self.assertEqual(line.display_type, result[line.sequence][1]) + + def test_group_by_delivery_picking_multi_steps(self): + warehouse = self.env.ref("stock.warehouse0") + warehouse.write({"delivery_steps": "pick_pack_ship"}) + + self.order_1.action_confirm() + self.order_2.action_confirm() + + invoice = (self.order_1 + self.order_2)._create_invoices() + self.assertEqual(len(invoice), 1) + result = { + 10: (self.order_1.order_line.move_ids.picking_id.name, "line_section"), + 20: (self.order_1.order_line.name, False), + 30: (self.order_2.order_line.move_ids.picking_id.name, "line_section"), + 40: (self.order_2.order_line.name, False), + } + for line in invoice.invoice_line_ids.sorted("sequence"): + self.assertEqual(line.name, result[line.sequence][0]) + self.assertEqual(line.display_type, result[line.sequence][1]) + + def test_group_by_delivery_picking_backorder(self): + self.order_1.action_confirm() + self.order_2.action_confirm() + + delivery_1_move = self.order_1.order_line.move_ids + delivery_1_move.move_line_ids.qty_done = 2.0 + delivery_1 = delivery_1_move.picking_id + backorder_wiz_action = delivery_1.button_validate() + backorder_wiz = ( + self.env["stock.backorder.confirmation"] + .with_context(**backorder_wiz_action.get("context")) + .create({}) + ) + backorder_wiz.process() + delivery_1_backorder_move = self.order_1.order_line.move_ids - delivery_1_move + delivery_1_backorder = delivery_1_backorder_move.picking_id + delivery_2 = self.order_2.order_line.move_ids.picking_id + invoice = (self.order_1 + self.order_2)._create_invoices() + result = { + 10: ( + ", ".join([delivery_1.name, delivery_1_backorder.name]), + "line_section", + ), + 20: (self.order_1.order_line.name, False), + 30: (delivery_2.name, "line_section"), + 40: (self.order_2.order_line.name, False), + } + for line in invoice.invoice_line_ids.sorted("sequence"): + self.assertEqual(line.name, result[line.sequence][0]) + self.assertEqual(line.display_type, result[line.sequence][1]) diff --git a/account_invoice_section_picking/tests/test_account_invoice_section_picking_backorder.py b/account_invoice_section_picking/tests/test_account_invoice_section_picking_backorder.py new file mode 100644 index 00000000000..ea8e7df0970 --- /dev/null +++ b/account_invoice_section_picking/tests/test_account_invoice_section_picking_backorder.py @@ -0,0 +1,168 @@ +# Copyright 2021 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +import datetime +import logging +import re +from types import MethodType + +from psycopg2 import sql +from psycopg2.extensions import AsIs + +from .common import TestAccountInvoiceSectionPickingCommon + +_logger = logging.getLogger(__name__) + +try: + from freezegun import freeze_time +except (ImportError, IOError) as err: + _logger.debug(err) + + +class TestAccountInvoiceSectionPickingBackorder(TestAccountInvoiceSectionPickingCommon): + def setUp(self): + super().setUp() + self._monkey_patch_cursor_freeze_time() + self.addCleanup(self._reset_monkey_patch_cursor_freezetime) + + def _reset_monkey_patch_cursor_freezetime(self): + self.env.cr.execute = self.env.cr.standard_exec + + def _monkey_patch_cursor_freeze_time(self): + """Monkey patch cursor to replace SQL TIME/DATE keywords""" + # TODO: Extract this part into a fork of freezegun for odoo? + # Copied from https://github.com/CloverHealth/pytest-pgsql/blob/1.1.2/pytest_pgsql/time.py#L12-L24 # noqa + _TIMESTAMP_REPLACEMENT_FORMATS = ( + # Functions + ( + r"\b((NOW|CLOCK_TIMESTAMP|STATEMENT_TIMESTAMP|TRANSACTION_TIMESTAMP)\s*\(\s*\))", # noqa + r"'{:%Y-%m-%d %H:%M:%S.%f %z}'::TIMESTAMPTZ", + ), + ( + r"\b(TIMEOFDAY\s*\(\s*\))", + r"'{:%Y-%m-%d %H:%M:%S.%f %z}'::TEXT", + ), + # Keywords + (r"\b(CURRENT_DATE)\b", r"'{:%Y-%m-%d}'::DATE"), + (r"\b(CURRENT_TIME)\b", r"'{:%H:%M:%S.%f %z}'::TIMETZ"), + ( + r"\b(CURRENT_TIMESTAMP)\b", + r"'{:%Y-%m-%d %H:%M:%S.%f %z}'::TIMESTAMPTZ", + ), + (r"\b(LOCALTIME)\b", r"'{:%H:%M:%S.%f}'::TIME"), + (r"\b(LOCALTIMESTAMP)\b", r"'{:%Y-%m-%d %H:%M:%S.%f}'::TIMESTAMP"), + ) + + self.env.cr.standard_exec = self.env.cr.execute + + def frozen_time_execute(self, query, params=None, log_exceptions=None): + def replace_keywords(regex, replacement, timestamp, query): + return re.sub( + regex, + replacement.format(timestamp), + query, + flags=re.IGNORECASE, + ) + + timestamp = datetime.datetime.now() + for regex, replacement in _TIMESTAMP_REPLACEMENT_FORMATS: + if isinstance(query, sql.Composed): + new_composed = [] + for comp in query: + if isinstance(comp, sql.SQL): + comp = replace_keywords( + regex, replacement, timestamp, comp.string + ) + new_composed.append(sql.SQL(comp)) + else: + new_composed.append(comp) + query = sql.Composed(new_composed) + else: + query = replace_keywords(regex, replacement, timestamp, query) + if params is not None: + for i, param in enumerate(params): + if isinstance(param, AsIs): + new_param = AsIs( + replace_keywords( + regex, + replacement, + timestamp, + param.getquoted().decode(), + ) + ) + params[i] = new_param + return self.standard_exec( + query, params=params, log_exceptions=log_exceptions + ) + + self.env.cr.execute = MethodType(frozen_time_execute, self.env.cr) + + def test_group_by_delivery_picking_backorder_delivered_qties(self): + self.product_1.invoice_policy = "delivery" + with freeze_time("2021-12-10 10:00:00"): + self.order_1.action_confirm() + with freeze_time("2021-12-10 10:00:01"): + self.order_2.action_confirm() + with freeze_time("2021-12-10 10:00:02"): + delivery_1_move = self.order_1.order_line.move_ids + delivery_1_move.move_line_ids.qty_done = 2.0 + delivery_1 = delivery_1_move.picking_id + + backorder_wiz_action = delivery_1.button_validate() + backorder_wiz = ( + self.env["stock.backorder.confirmation"] + .with_context(**backorder_wiz_action.get("context")) + .create({}) + ) + backorder_wiz.process() + with freeze_time("2021-12-10 10:00:03"): + delivery_1_backorder_move = ( + self.order_1.order_line.move_ids - delivery_1_move + ) + delivery_1_backorder = delivery_1_backorder_move.picking_id + delivery_2_move = self.order_2.order_line.move_ids + delivery_2_move.move_line_ids.qty_done = 3.0 + delivery_2 = delivery_2_move.picking_id + backorder_wiz_action = delivery_2.button_validate() + backorder_wiz = ( + self.env["stock.backorder.confirmation"] + .with_context(**backorder_wiz_action.get("context")) + .create({}) + ) + backorder_wiz.process() + with freeze_time("2021-12-10 10:00:04"): + delivery_2_backorder_move = ( + self.order_2.order_line.move_ids - delivery_2_move + ) + delivery_2_backorder = delivery_2_backorder_move.picking_id + self.env["account.move"].flush([]) + invoice = (self.order_1 + self.order_2)._create_invoices() + result = { + 10: (delivery_1.name, "line_section"), + 20: (self.order_1.order_line.name, False), + 30: (delivery_2.name, "line_section"), + 40: (self.order_2.order_line.name, False), + } + for line in invoice.invoice_line_ids.sorted("sequence"): + self.assertEqual(line.name, result[line.sequence][0]) + self.assertEqual(line.display_type, result[line.sequence][1]) + with freeze_time("2021-12-10 10:00:05"): + delivery_1_backorder.action_assign() + with freeze_time("2021-12-10 10:00:06"): + delivery_2_backorder.action_assign() + with freeze_time("2021-12-10 10:00:07"): + delivery_1_backorder_move.move_line_ids.qty_done = 3.0 + delivery_1_backorder.button_validate() + with freeze_time("2021-12-10 10:00:08"): + delivery_2_backorder_move.move_line_ids.qty_done = 2.0 + delivery_2_backorder.button_validate() + with freeze_time("2021-12-10 10:00:09"): + invoice_backorder = (self.order_1 + self.order_2)._create_invoices() + result_backorder = { + 10: (delivery_1_backorder.name, "line_section"), + 20: (self.order_1.order_line.name, False), + 30: (delivery_2_backorder.name, "line_section"), + 40: (self.order_2.order_line.name, False), + } + for line in invoice_backorder.invoice_line_ids.sorted("sequence"): + self.assertEqual(line.name, result_backorder[line.sequence][0]) + self.assertEqual(line.display_type, result_backorder[line.sequence][1]) diff --git a/setup/account_invoice_section_picking/odoo/addons/account_invoice_section_picking b/setup/account_invoice_section_picking/odoo/addons/account_invoice_section_picking new file mode 120000 index 00000000000..6a203601614 --- /dev/null +++ b/setup/account_invoice_section_picking/odoo/addons/account_invoice_section_picking @@ -0,0 +1 @@ +../../../../account_invoice_section_picking \ No newline at end of file diff --git a/setup/account_invoice_section_picking/setup.py b/setup/account_invoice_section_picking/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_invoice_section_picking/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 00000000000..09e6cb1152a --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1 @@ +git+https://github.com/OCA/account-invoicing@refs/pull/1050/head#subdirectory=setup/account_invoice_section_sale_order