From 645c9e681a510d4a205289a355e446d6396ff643 Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Wed, 25 Oct 2023 15:27:01 +0200 Subject: [PATCH] [ADD] account_cutoff_accrual_sale_stock --- account_cutoff_accrual_sale_stock/README.rst | 0 account_cutoff_accrual_sale_stock/__init__.py | 4 + .../__manifest__.py | 21 +++ .../models/__init__.py | 4 + .../models/sale_order_line.py | 56 ++++++ .../tests/__init__.py | 1 + .../tests/common.py | 88 +++++++++ .../tests/test_cutoff_revenue.py | 177 ++++++++++++++++++ .../addons/account_cutoff_accrual_sale_stock | 1 + .../setup.py | 6 + 10 files changed, 358 insertions(+) create mode 100644 account_cutoff_accrual_sale_stock/README.rst create mode 100644 account_cutoff_accrual_sale_stock/__init__.py create mode 100644 account_cutoff_accrual_sale_stock/__manifest__.py create mode 100644 account_cutoff_accrual_sale_stock/models/__init__.py create mode 100644 account_cutoff_accrual_sale_stock/models/sale_order_line.py create mode 100644 account_cutoff_accrual_sale_stock/tests/__init__.py create mode 100644 account_cutoff_accrual_sale_stock/tests/common.py create mode 100644 account_cutoff_accrual_sale_stock/tests/test_cutoff_revenue.py create mode 120000 setup/account_cutoff_accrual_sale_stock/odoo/addons/account_cutoff_accrual_sale_stock create mode 100644 setup/account_cutoff_accrual_sale_stock/setup.py diff --git a/account_cutoff_accrual_sale_stock/README.rst b/account_cutoff_accrual_sale_stock/README.rst new file mode 100644 index 00000000000..e69de29bb2d diff --git a/account_cutoff_accrual_sale_stock/__init__.py b/account_cutoff_accrual_sale_stock/__init__.py new file mode 100644 index 00000000000..86337901b11 --- /dev/null +++ b/account_cutoff_accrual_sale_stock/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2018 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/account_cutoff_accrual_sale_stock/__manifest__.py b/account_cutoff_accrual_sale_stock/__manifest__.py new file mode 100644 index 00000000000..cd33bb2caa0 --- /dev/null +++ b/account_cutoff_accrual_sale_stock/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2018 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Account Cut-off Accrual Sale Stock", + "version": "16.0.1.0.0", + "category": "Accounting & Finance", + "license": "AGPL-3", + "summary": "Glue module for Cut-Off Accruals on Sales with Stock Deliveries", + "author": "BCIM, Odoo Community Association (OCA)", + "maintainers": ["jbaudoux"], + "website": "https://github.com/OCA/account-closing", + "depends": [ + "account_cutoff_accrual_sale", + "account_cutoff_accrual_order_stock_base", + "sale_stock", + ], + "installable": True, + "application": False, + "auto_install": True, +} diff --git a/account_cutoff_accrual_sale_stock/models/__init__.py b/account_cutoff_accrual_sale_stock/models/__init__.py new file mode 100644 index 00000000000..b0c9d05c9d4 --- /dev/null +++ b/account_cutoff_accrual_sale_stock/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2018 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import sale_order_line diff --git a/account_cutoff_accrual_sale_stock/models/sale_order_line.py b/account_cutoff_accrual_sale_stock/models/sale_order_line.py new file mode 100644 index 00000000000..dad6aaf5a56 --- /dev/null +++ b/account_cutoff_accrual_sale_stock/models/sale_order_line.py @@ -0,0 +1,56 @@ +# Copyright 2018 Jacques-Etienne Baudoux (BCIM sprl) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) + + +class SaleOrderLine(models.Model): + _name = "sale.order.line" + _inherit = ["sale.order.line", "order.line.cutoff.accrual.mixin"] + + def _get_cutoff_accrual_lines_delivered_after(self, cutoff): + lines = super()._get_cutoff_accrual_lines_delivered_after(cutoff) + cutoff_nextday = cutoff._nextday_start_dt() + # Take all moves done after the cutoff date + moves_after = self.env["stock.move"].search( + [ + ("state", "=", "done"), + ("date", ">=", cutoff_nextday), + ("sale_line_id", "!=", False), + ], + order="id", + ) + sale_ids = set(moves_after.sale_line_id.order_id.ids) + sales = self.env["sale.order"].browse(sale_ids) + lines |= sales.order_line + return lines + + def _get_cutoff_accrual_delivered_min_date(self): + """Return first delivery date""" + self.ensure_one() + stock_moves = self.move_ids.filtered(lambda m: m.state == "done") + if not stock_moves: + return + return min(stock_moves.mapped("date")).date() + + def _get_cutoff_accrual_delivered_quantity(self, cutoff): + self.ensure_one() + delivered_qty = super()._get_cutoff_accrual_delivered_quantity(cutoff) + # The quantity delivered on the SO line must be deducted from all + # moves done after the cutoff date. + cutoff_nextday = cutoff._nextday_start_dt() + moves_after = self.order_id.procurement_group_id.stock_move_ids.filtered( + lambda r: r.state == "done" and r.date >= cutoff_nextday + ) + for move in moves_after: + if move.product_uom != self.product_uom: + delivered_qty -= move.product_uom._compute_quantity( + move.product_uom_qty, self.product_uom + ) + else: + delivered_qty -= move.product_uom_qty + return delivered_qty diff --git a/account_cutoff_accrual_sale_stock/tests/__init__.py b/account_cutoff_accrual_sale_stock/tests/__init__.py new file mode 100644 index 00000000000..34d4dd52a80 --- /dev/null +++ b/account_cutoff_accrual_sale_stock/tests/__init__.py @@ -0,0 +1 @@ +from . import test_cutoff_revenue diff --git a/account_cutoff_accrual_sale_stock/tests/common.py b/account_cutoff_accrual_sale_stock/tests/common.py new file mode 100644 index 00000000000..c500568c4a0 --- /dev/null +++ b/account_cutoff_accrual_sale_stock/tests/common.py @@ -0,0 +1,88 @@ +# Copyright 2018 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import Command, fields + +from odoo.addons.account_cutoff_accrual_order_base.tests.common import ( + TestAccountCutoffAccrualOrderCommon, +) + + +class TestAccountCutoffAccrualSaleCommon(TestAccountCutoffAccrualOrderCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.tax_sale = cls.env.company.account_sale_tax_id + cls.cutoff_account = cls.env["account.account"].create( + { + "name": "account accrued revenue", + "code": "accountAccruedExpense", + "account_type": "asset_current", + "company_id": cls.env.company.id, + } + ) + cls.tax_sale.account_accrued_revenue_id = cls.cutoff_account + # Removing all existing SO + cls.env.cr.execute("DELETE FROM sale_order;") + # Create SO + cls.so = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "order_line": [ + Command.create( + { + "name": p.name, + "product_id": p.id, + "product_uom_qty": 5, + "product_uom": p.uom_id.id, + "price_unit": 100, + "analytic_distribution": { + str(cls.analytic_account.id): 100.0 + }, + "tax_id": [Command.set(cls.tax_sale.ids)], + }, + ) + for p in cls.products + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + type_cutoff = "accrued_revenue" + cls.revenue_cutoff = ( + cls.env["account.cutoff"] + .with_context(default_cutoff_type=type_cutoff) + .create( + { + "cutoff_type": type_cutoff, + "order_line_model": "sale.order.line", + "company_id": 1, + "cutoff_date": fields.Date.today(), + } + ) + ) + + def _confirm_so_and_do_picking(self, qty_done): + self.so.action_confirm() + self.assertEqual( + self.so.invoice_status, + "no", + 'SO invoice_status should be "nothing to invoice" after confirming', + ) + # Deliver + pick = self.so.picking_ids + pick.action_assign() + pick.move_line_ids.write({"qty_done": qty_done}) # receive 2/5 # deliver 2/5 + pick._action_done() + self.assertEqual( + self.so.invoice_status, + "to invoice", + 'SO invoice_status should be "to invoice" after partial delivery', + ) + qties = [sol.qty_delivered for sol in self.so.order_line] + self.assertEqual( + qties, + [qty_done for p in self.products], + "Delivered quantities are wrong after partial delivery", + ) diff --git a/account_cutoff_accrual_sale_stock/tests/test_cutoff_revenue.py b/account_cutoff_accrual_sale_stock/tests/test_cutoff_revenue.py new file mode 100644 index 00000000000..9cf5b2d3309 --- /dev/null +++ b/account_cutoff_accrual_sale_stock/tests/test_cutoff_revenue.py @@ -0,0 +1,177 @@ +# Copyright 2018 Jacques-Etienne Baudoux (BCIM) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from datetime import timedelta + +from .common import TestAccountCutoffAccrualSaleCommon + + +class TestAccountCutoffAccrualSale(TestAccountCutoffAccrualSaleCommon): + def test_accrued_revenue_empty(self): + """Test cutoff when there is no SO.""" + cutoff = self.revenue_cutoff + cutoff.get_lines() + self.assertEqual( + len(cutoff.line_ids), 0, "There should be no SO line to process" + ) + + def test_revenue_analytic_distribution(self): + cutoff = self.revenue_cutoff + self._confirm_so_and_do_picking(2) + cutoff.get_lines() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertDictEqual( + line.analytic_distribution, + {str(self.analytic_account.id): 100.0}, + "Analytic distribution is not correctly set", + ) + + def test_revenue_tax_line(self): + cutoff = self.revenue_cutoff + self._confirm_so_and_do_picking(2) + cutoff.get_lines() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + len(line.tax_line_ids), 1, "tax lines is not correctly set" + ) + self.assertEqual(line.tax_line_ids.cutoff_account_id, self.cutoff_account) + self.assertEqual(line.tax_line_ids.tax_id, self.tax_sale) + self.assertEqual(line.tax_line_ids.base, 200) + self.assertEqual(line.tax_line_ids.amount, 30) + self.assertEqual(line.tax_line_ids.cutoff_amount, 30) + + def test_accrued_revenue_on_so_not_invoiced(self): + """Test cutoff based on SO where qty_delivered > qty_invoiced.""" + cutoff = self.revenue_cutoff + self._confirm_so_and_do_picking(2) + cutoff.get_lines() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 100 * 2, "SO line cutoff amount incorrect" + ) + # Make invoice + self.so._create_invoices(final=True) + # - invoice is in draft, no change to cutoff + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 100 * 2, "SO line cutoff amount incorrect" + ) + # Validate invoice + self.so.invoice_ids.action_post() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual(line.cutoff_amount, 0, "SO line cutoff amount incorrect") + # Make a refund - the refund reset the SO lines qty_invoiced + self._refund_invoice(self.so.invoice_ids) + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual(line.cutoff_amount, 200, "SO line cutoff amount incorrect") + + def test_accrued_revenue_on_so_all_invoiced(self): + """Test cutoff based on SO where qty_delivered = qty_invoiced.""" + cutoff = self.revenue_cutoff + self._confirm_so_and_do_picking(2) + # Make invoice + self.so._create_invoices(final=True) + # Validate invoice + self.so.invoice_ids.action_post() + cutoff.get_lines() + self.assertEqual(len(cutoff.line_ids), 0, "No cutoff lines should be found") + # Make a refund - the refund reset qty_invoiced + self._refund_invoice(self.so.invoice_ids) + self.assertEqual(len(cutoff.line_ids), 2, "No cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual(line.cutoff_amount, 200, "SO line cutoff amount incorrect") + + def test_accrued_revenue_on_so_draft_invoice(self): + """Test cutoff based on SO where qty_delivered = qty_invoiced but the. + + invoice is still in draft + """ + cutoff = self.revenue_cutoff + self._confirm_so_and_do_picking(2) + # Make invoice + self.so._create_invoices(final=True) + # - invoice is in draft, no change to cutoff + cutoff.get_lines() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 100 * 2, "SO line cutoff amount incorrect" + ) + # Validate invoice + self.so.invoice_ids.action_post() + self.assertEqual(len(cutoff.line_ids), 2, "no cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual(line.cutoff_amount, 0, "SO line cutoff amount incorrect") + # Make a refund - the refund reset SO lines qty_invoiced + self._refund_invoice(self.so.invoice_ids) + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual(line.cutoff_amount, 200, "SO line cutoff amount incorrect") + + def test_accrued_revenue_on_so_not_invoiced_after_cutoff(self): + """Test cutoff based on SO where qty_delivered > qty_invoiced. + + And make invoice after cutoff date + """ + cutoff = self.revenue_cutoff + self._confirm_so_and_do_picking(2) + cutoff.get_lines() + # Make invoice + self.so._create_invoices(final=True) + # - invoice is in draft, no change to cutoff + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 100 * 2, "SO line cutoff amount incorrect" + ) + # Validate invoice after cutoff + self.so.invoice_ids.invoice_date = cutoff.cutoff_date + timedelta(days=1) + self.so.invoice_ids.action_post() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 100 * 2, "SO line cutoff amount incorrect" + ) + # Make a refund after cutoff + refund = self._refund_invoice(self.so.invoice_ids, post=False) + refund.date = cutoff.cutoff_date + timedelta(days=1) + refund.action_post() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 100 * 2, "SO line cutoff amount incorrect" + ) + + def test_accrued_revenue_on_so_all_invoiced_after_cutoff(self): + """Test cutoff based on SO where qty_delivered = qty_invoiced. + + And make invoice after cutoff date + """ + cutoff = self.revenue_cutoff + self._confirm_so_and_do_picking(2) + # Make invoice + self.so._create_invoices(final=True) + # Validate invoice after cutoff + self.so.invoice_ids.invoice_date = cutoff.cutoff_date + timedelta(days=1) + self.so.invoice_ids.action_post() + cutoff.get_lines() + self.assertEqual(len(cutoff.line_ids), 2, "2 cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 2 * 100, "SO line cutoff amount incorrect" + ) + # Make a refund - the refund reset SO lines qty_invoiced + refund = self._refund_invoice(self.so.invoice_ids, post=False) + refund.date = cutoff.cutoff_date + timedelta(days=1) + refund.action_post() + self.assertEqual(len(cutoff.line_ids), 2, "no cutoff lines should be found") + for line in cutoff.line_ids: + self.assertEqual( + line.cutoff_amount, 100 * 2, "SO line cutoff amount incorrect" + ) diff --git a/setup/account_cutoff_accrual_sale_stock/odoo/addons/account_cutoff_accrual_sale_stock b/setup/account_cutoff_accrual_sale_stock/odoo/addons/account_cutoff_accrual_sale_stock new file mode 120000 index 00000000000..f8b490af6fd --- /dev/null +++ b/setup/account_cutoff_accrual_sale_stock/odoo/addons/account_cutoff_accrual_sale_stock @@ -0,0 +1 @@ +../../../../account_cutoff_accrual_sale_stock \ No newline at end of file diff --git a/setup/account_cutoff_accrual_sale_stock/setup.py b/setup/account_cutoff_accrual_sale_stock/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_cutoff_accrual_sale_stock/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)