From b898c1dd1c413b5283cba716578b62de93037ed4 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 7 Jul 2020 12:35:30 +0200 Subject: [PATCH 1/9] [13.0]]ADD] Add acconut_invoicing_mode Add three modules helping on automatically invoicing customers. The base module `account_invoice_base_invoicing_mode` does not actually do anything but adds a selection field on partner to allow assigning an invoicing mode to a customer. And a checkbox to choose regrouping invoices. The two other modules add specific invoicing mode. The module `account_invoice_mode_monthly` creates monthly invoices for customer on a specific day (configuration is in Accounting Settings) The module `account_invoice_mode_at_shipping` creates invoices on the shipping of the goods. Those modules use queue_job to generate and validate invoices. --- .../__init__.py | 2 + .../__manifest__.py | 13 ++ .../models/__init__.py | 4 + .../models/account_invoice.py | 15 ++ .../models/queue_job.py | 35 +++++ .../models/res_partner.py | 15 ++ .../models/sale_order.py | 12 ++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 9 ++ .../tests/__init__.py | 1 + .../tests/test_base_invoicing_mode.py | 11 ++ .../views/res_partner.xml | 16 ++ account_invoice_mode_at_shipping/__init__.py | 2 + .../__manifest__.py | 12 ++ .../models/__init__.py | 2 + .../models/res_partner.py | 10 ++ .../models/stock_picking.py | 35 +++++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 4 + .../tests/__init__.py | 1 + .../tests/test_invoice_mode_at_shipping.py | 77 ++++++++++ account_invoice_mode_monthly/__init__.py | 2 + account_invoice_mode_monthly/__manifest__.py | 13 ++ account_invoice_mode_monthly/data/ir_cron.xml | 18 +++ .../models/__init__.py | 4 + .../models/res_company.py | 21 +++ .../models/res_config_settings.py | 14 ++ .../models/res_partner.py | 10 ++ .../models/sale_order.py | 102 +++++++++++++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 4 + .../readme/INSTALL.rst | 0 .../tests/__init__.py | 2 + .../tests/test_invoice_mode_monthly.py | 138 ++++++++++++++++++ .../test_invoice_mode_monthly_is_it_today.py | 63 ++++++++ .../views/res_config_settings_views.xml | 43 ++++++ requirements.txt | 1 + .../account_invoice_base_invoicing_mode | 1 + .../setup.py | 6 + .../addons/account_invoice_mode_at_shipping | 1 + .../account_invoice_mode_at_shipping/setup.py | 6 + .../odoo/addons/account_invoice_mode_monthly | 1 + setup/account_invoice_mode_monthly/setup.py | 6 + 43 files changed, 741 insertions(+) create mode 100644 account_invoice_base_invoicing_mode/__init__.py create mode 100644 account_invoice_base_invoicing_mode/__manifest__.py create mode 100644 account_invoice_base_invoicing_mode/models/__init__.py create mode 100644 account_invoice_base_invoicing_mode/models/account_invoice.py create mode 100644 account_invoice_base_invoicing_mode/models/queue_job.py create mode 100644 account_invoice_base_invoicing_mode/models/res_partner.py create mode 100644 account_invoice_base_invoicing_mode/models/sale_order.py create mode 100644 account_invoice_base_invoicing_mode/readme/CONTRIBUTORS.rst create mode 100644 account_invoice_base_invoicing_mode/readme/DESCRIPTION.rst create mode 100644 account_invoice_base_invoicing_mode/tests/__init__.py create mode 100644 account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py create mode 100644 account_invoice_base_invoicing_mode/views/res_partner.xml create mode 100644 account_invoice_mode_at_shipping/__init__.py create mode 100644 account_invoice_mode_at_shipping/__manifest__.py create mode 100644 account_invoice_mode_at_shipping/models/__init__.py create mode 100644 account_invoice_mode_at_shipping/models/res_partner.py create mode 100644 account_invoice_mode_at_shipping/models/stock_picking.py create mode 100644 account_invoice_mode_at_shipping/readme/CONTRIBUTORS.rst create mode 100644 account_invoice_mode_at_shipping/readme/DESCRIPTION.rst create mode 100644 account_invoice_mode_at_shipping/tests/__init__.py create mode 100644 account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py create mode 100644 account_invoice_mode_monthly/__init__.py create mode 100644 account_invoice_mode_monthly/__manifest__.py create mode 100644 account_invoice_mode_monthly/data/ir_cron.xml create mode 100644 account_invoice_mode_monthly/models/__init__.py create mode 100644 account_invoice_mode_monthly/models/res_company.py create mode 100644 account_invoice_mode_monthly/models/res_config_settings.py create mode 100644 account_invoice_mode_monthly/models/res_partner.py create mode 100644 account_invoice_mode_monthly/models/sale_order.py create mode 100644 account_invoice_mode_monthly/readme/CONTRIBUTORS.rst create mode 100644 account_invoice_mode_monthly/readme/DESCRIPTION.rst create mode 100644 account_invoice_mode_monthly/readme/INSTALL.rst create mode 100644 account_invoice_mode_monthly/tests/__init__.py create mode 100644 account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py create mode 100644 account_invoice_mode_monthly/tests/test_invoice_mode_monthly_is_it_today.py create mode 100644 account_invoice_mode_monthly/views/res_config_settings_views.xml create mode 100644 requirements.txt create mode 120000 setup/account_invoice_base_invoicing_mode/odoo/addons/account_invoice_base_invoicing_mode create mode 100644 setup/account_invoice_base_invoicing_mode/setup.py create mode 120000 setup/account_invoice_mode_at_shipping/odoo/addons/account_invoice_mode_at_shipping create mode 100644 setup/account_invoice_mode_at_shipping/setup.py create mode 120000 setup/account_invoice_mode_monthly/odoo/addons/account_invoice_mode_monthly create mode 100644 setup/account_invoice_mode_monthly/setup.py diff --git a/account_invoice_base_invoicing_mode/__init__.py b/account_invoice_base_invoicing_mode/__init__.py new file mode 100644 index 00000000000..0ee8b5073e2 --- /dev/null +++ b/account_invoice_base_invoicing_mode/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tests diff --git a/account_invoice_base_invoicing_mode/__manifest__.py b/account_invoice_base_invoicing_mode/__manifest__.py new file mode 100644 index 00000000000..bbfa56d5d77 --- /dev/null +++ b/account_invoice_base_invoicing_mode/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Invoice Base Invoicing Mode", + "version": "13.0.1.0.0", + "summary": "Base module for handling multiple invoicing mode", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-invoicing", + "license": "AGPL-3", + "category": "Accounting & Finance", + "depends": ["account", "queue_job", "sale"], + "data": ["views/res_partner.xml"], +} diff --git a/account_invoice_base_invoicing_mode/models/__init__.py b/account_invoice_base_invoicing_mode/models/__init__.py new file mode 100644 index 00000000000..fcce316ab83 --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/__init__.py @@ -0,0 +1,4 @@ +from . import account_invoice +from . import queue_job +from . import res_partner +from . import sale_order diff --git a/account_invoice_base_invoicing_mode/models/account_invoice.py b/account_invoice_base_invoicing_mode/models/account_invoice.py new file mode 100644 index 00000000000..ff4dd27039c --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/account_invoice.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import models + +from odoo.addons.queue_job.job import job, related_action + + +class AccountMove(models.Model): + _inherit = "account.move" + + @job(default_channel="root.invoice_validation") + @related_action(action="related_action_open_invoice") + def _validate_invoice(self): + return self.action_post() diff --git a/account_invoice_base_invoicing_mode/models/queue_job.py b/account_invoice_base_invoicing_mode/models/queue_job.py new file mode 100644 index 00000000000..2365261768d --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/queue_job.py @@ -0,0 +1,35 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import _, models + + +class QueueJob(models.Model): + _inherit = "queue.job" + + def related_action_open_invoice(self): + """Open a form view with the invoice related to the job.""" + self.ensure_one() + model_name = self.model_name + records = self.env[model_name].browse(self.record_ids).exists() + if not records: + return None + action = { + "name": _("Related Record"), + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": records._name, + } + if len(records) == 1: + action["res_id"] = records.id + else: + action.update( + { + "name": _("Related Records"), + "view_mode": "tree,form", + "view_id": "account.view_invoice_tree", + "domain": [("id", "in", records.ids)], + } + ) + return action diff --git a/account_invoice_base_invoicing_mode/models/res_partner.py b/account_invoice_base_invoicing_mode/models/res_partner.py new file mode 100644 index 00000000000..9b908fa45ad --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/res_partner.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + invoicing_mode = fields.Selection([("standard", "Standard")], default="standard") + one_invoice_per_order = fields.Boolean( + "One invoice per order", + default=False, + help="Do not group sale order into one invoice.", + ) diff --git a/account_invoice_base_invoicing_mode/models/sale_order.py b/account_invoice_base_invoicing_mode/models/sale_order.py new file mode 100644 index 00000000000..f22ee4cfefa --- /dev/null +++ b/account_invoice_base_invoicing_mode/models/sale_order.py @@ -0,0 +1,12 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + invoicing_mode = fields.Selection( + related="partner_invoice_id.invoicing_mode", readonly=True + ) diff --git a/account_invoice_base_invoicing_mode/readme/CONTRIBUTORS.rst b/account_invoice_base_invoicing_mode/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..b180cbde2b2 --- /dev/null +++ b/account_invoice_base_invoicing_mode/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `_: + + * Thierry Ducrest diff --git a/account_invoice_base_invoicing_mode/readme/DESCRIPTION.rst b/account_invoice_base_invoicing_mode/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..453a040bfc0 --- /dev/null +++ b/account_invoice_base_invoicing_mode/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +This is a base module for implementing different invoicing mode for customers. +It adds a selection field `invoicing_mode` in the Accounting tab of the partner +with a default value (Odoo standard invoicing mode). +But it serves no purpose installed on its own. + +The following modules use it to install specific invoicing mode : + + * `account_invoice_mode_at_shipping` + * `account_invoice_mode_monthly` diff --git a/account_invoice_base_invoicing_mode/tests/__init__.py b/account_invoice_base_invoicing_mode/tests/__init__.py new file mode 100644 index 00000000000..d7710a099a2 --- /dev/null +++ b/account_invoice_base_invoicing_mode/tests/__init__.py @@ -0,0 +1 @@ +from . import test_base_invoicing_mode diff --git a/account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py b/account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py new file mode 100644 index 00000000000..5e0bf6f9866 --- /dev/null +++ b/account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py @@ -0,0 +1,11 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + + +from odoo.tests.common import SavepointCase + + +class TestBaseInvoicingMode(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() diff --git a/account_invoice_base_invoicing_mode/views/res_partner.xml b/account_invoice_base_invoicing_mode/views/res_partner.xml new file mode 100644 index 00000000000..db51c1d1d35 --- /dev/null +++ b/account_invoice_base_invoicing_mode/views/res_partner.xml @@ -0,0 +1,16 @@ + + + + view_partner_property_form_base_invoicing_mode + res.partner + + + + + + + + + + + diff --git a/account_invoice_mode_at_shipping/__init__.py b/account_invoice_mode_at_shipping/__init__.py new file mode 100644 index 00000000000..0ee8b5073e2 --- /dev/null +++ b/account_invoice_mode_at_shipping/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tests diff --git a/account_invoice_mode_at_shipping/__manifest__.py b/account_invoice_mode_at_shipping/__manifest__.py new file mode 100644 index 00000000000..4cfb7f0063b --- /dev/null +++ b/account_invoice_mode_at_shipping/__manifest__.py @@ -0,0 +1,12 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Invoice Mode At Shipping", + "version": "13.0.1.0.0", + "summary": "Create invoices automatically when goods are shipped.", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-invoicing", + "license": "AGPL-3", + "category": "Accounting & Finance", + "depends": ["account", "account_invoice_base_invoicing_mode", "queue_job", "stock"], +} diff --git a/account_invoice_mode_at_shipping/models/__init__.py b/account_invoice_mode_at_shipping/models/__init__.py new file mode 100644 index 00000000000..548fc596ae1 --- /dev/null +++ b/account_invoice_mode_at_shipping/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_partner +from . import stock_picking diff --git a/account_invoice_mode_at_shipping/models/res_partner.py b/account_invoice_mode_at_shipping/models/res_partner.py new file mode 100644 index 00000000000..59a5a73f252 --- /dev/null +++ b/account_invoice_mode_at_shipping/models/res_partner.py @@ -0,0 +1,10 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + invoicing_mode = fields.Selection(selection_add=([("at_shipping", "At Shipping")])) diff --git a/account_invoice_mode_at_shipping/models/stock_picking.py b/account_invoice_mode_at_shipping/models/stock_picking.py new file mode 100644 index 00000000000..3defcf213cf --- /dev/null +++ b/account_invoice_mode_at_shipping/models/stock_picking.py @@ -0,0 +1,35 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import models + +from odoo.addons.queue_job.job import job + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + def action_done(self): + res = super().action_done() + picking_to_invoice = self.filtered( + lambda r: r.sale_id.partner_invoice_id.invoicing_mode == "at_shipping" + and r.picking_type_code == "outgoing" + ) + for picking in picking_to_invoice: + picking.with_delay()._invoicing_at_shipping() + return res + + @job(default_channel="root.invoice_at_shipping") + def _invoicing_at_shipping(self): + self.ensure_one() + sale_order_ids = self._get_sales_order_to_invoice() + partner = sale_order_ids.partner_invoice_id + invoices = sale_order_ids._create_invoices( + grouped=partner.one_invoice_per_order + ) + for invoice in invoices: + invoice.with_delay()._validate_invoice() + return invoices + + def _get_sales_order_to_invoice(self): + return self.mapped("move_lines.group_id.sale_id") diff --git a/account_invoice_mode_at_shipping/readme/CONTRIBUTORS.rst b/account_invoice_mode_at_shipping/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..b180cbde2b2 --- /dev/null +++ b/account_invoice_mode_at_shipping/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `_: + + * Thierry Ducrest diff --git a/account_invoice_mode_at_shipping/readme/DESCRIPTION.rst b/account_invoice_mode_at_shipping/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..cfbffff5ae0 --- /dev/null +++ b/account_invoice_mode_at_shipping/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This module allows to select a `At shipping` invoicing mode for a customer. +It is based on `account_invoice_base_invoicing_mode`. +When this mode is selected the customer will be invoiced automatically on +delivery of the goods. diff --git a/account_invoice_mode_at_shipping/tests/__init__.py b/account_invoice_mode_at_shipping/tests/__init__.py new file mode 100644 index 00000000000..094b649436f --- /dev/null +++ b/account_invoice_mode_at_shipping/tests/__init__.py @@ -0,0 +1 @@ +from . import test_invoice_mode_at_shipping diff --git a/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py b/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py new file mode 100644 index 00000000000..f93f4702186 --- /dev/null +++ b/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py @@ -0,0 +1,77 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.tests.common import SavepointCase + + +class TestInvoiceModeAtShipping(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env.ref("base.res_partner_1") + cls.product = cls.env.ref("product.product_delivery_01") + cls.so1 = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "order_line": [ + ( + 0, + 0, + { + "name": "Line one", + "product_id": cls.product.id, + "product_uom_qty": 4, + "product_uom": cls.product.uom_id.id, + "price_unit": 123, + }, + ) + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + stock_location = cls.env.ref("stock.stock_location_stock") + inventory = cls.env["stock.inventory"].create( + { + "name": "Test Inventory", + "product_ids": [(6, 0, cls.product.ids)], + "state": "confirm", + "line_ids": [ + ( + 0, + 0, + { + "product_qty": 100, + "location_id": stock_location.id, + "product_id": cls.product.id, + "product_uom_id": cls.product.uom_id.id, + }, + ) + ], + } + ) + inventory.action_validate() + + def test_invoice_created_at_shipping(self): + """Check that an invoice is created when goods are shipped.""" + self.partner.invoicing_mode = "at_shipping" + self.so1.action_confirm() + for picking in self.so1.picking_ids: + for line in picking.move_lines: + line.quantity_done = line.product_uom_qty + picking.action_assign() + picking.with_context(test_queue_job_no_delay=True).button_validate() + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(self.so1.invoice_ids.state, "posted") + + def test_invoice_not_created_at_shipping(self): + """Check that an invoice is created when goods are shipped.""" + self.partner.invoicing_mode = "standard" + self.so1.action_confirm() + for picking in self.so1.picking_ids: + for line in picking.move_lines: + line.quantity_done = line.product_uom_qty + picking.action_assign() + picking.button_validate() + self.assertEqual(len(self.so1.invoice_ids), 0) diff --git a/account_invoice_mode_monthly/__init__.py b/account_invoice_mode_monthly/__init__.py new file mode 100644 index 00000000000..0ee8b5073e2 --- /dev/null +++ b/account_invoice_mode_monthly/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import tests diff --git a/account_invoice_mode_monthly/__manifest__.py b/account_invoice_mode_monthly/__manifest__.py new file mode 100644 index 00000000000..43f24f6e71b --- /dev/null +++ b/account_invoice_mode_monthly/__manifest__.py @@ -0,0 +1,13 @@ +# Copyright 2020 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Invoice Mode Monthly", + "version": "13.0.1.0.0", + "summary": "Create invoices automatically on a monthly basis.", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/account-invoicing", + "license": "AGPL-3", + "category": "Accounting & Finance", + "depends": ["account", "account_invoice_base_invoicing_mode", "queue_job", "sale"], + "data": ["data/ir_cron.xml", "views/res_config_settings_views.xml"], +} diff --git a/account_invoice_mode_monthly/data/ir_cron.xml b/account_invoice_mode_monthly/data/ir_cron.xml new file mode 100644 index 00000000000..0410250612f --- /dev/null +++ b/account_invoice_mode_monthly/data/ir_cron.xml @@ -0,0 +1,18 @@ + + + + Generate Monthly Invoices + + + 1 + days + -1 + + + model.cron_generate_monthly_invoices() + + + diff --git a/account_invoice_mode_monthly/models/__init__.py b/account_invoice_mode_monthly/models/__init__.py new file mode 100644 index 00000000000..ea64a69d2e0 --- /dev/null +++ b/account_invoice_mode_monthly/models/__init__.py @@ -0,0 +1,4 @@ +from . import res_company +from . import res_config_settings +from . import res_partner +from . import sale_order diff --git a/account_invoice_mode_monthly/models/res_company.py b/account_invoice_mode_monthly/models/res_company.py new file mode 100644 index 00000000000..614a77c7cc3 --- /dev/null +++ b/account_invoice_mode_monthly/models/res_company.py @@ -0,0 +1,21 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + invoicing_mode_monthly_day_todo = fields.Integer( + "Invoicing Day", + default="31", + help="Day of the month to execute the invoicing. For a number higher" + "than the number of days in a month, the invoicing will be" + "executed on the last day of the month.", + ) + invoicing_mode_monthly_last_execution = fields.Datetime( + string="Last execution", + help="Last execution of monthly invoicing.", + readonly=True, + ) diff --git a/account_invoice_mode_monthly/models/res_config_settings.py b/account_invoice_mode_monthly/models/res_config_settings.py new file mode 100644 index 00000000000..a2c560c73c9 --- /dev/null +++ b/account_invoice_mode_monthly/models/res_config_settings.py @@ -0,0 +1,14 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + res_invoicing_mode_monthly_day_todo = fields.Integer( + related="company_id.invoicing_mode_monthly_day_todo", readonly=False + ) + invoicing_mode_monthly_last_execution = fields.Datetime( + related="company_id.invoicing_mode_monthly_last_execution", readonly=True + ) diff --git a/account_invoice_mode_monthly/models/res_partner.py b/account_invoice_mode_monthly/models/res_partner.py new file mode 100644 index 00000000000..99953595e59 --- /dev/null +++ b/account_invoice_mode_monthly/models/res_partner.py @@ -0,0 +1,10 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + invoicing_mode = fields.Selection(selection_add=([("monthly", "Monthly")])) diff --git a/account_invoice_mode_monthly/models/sale_order.py b/account_invoice_mode_monthly/models/sale_order.py new file mode 100644 index 00000000000..70758847e4f --- /dev/null +++ b/account_invoice_mode_monthly/models/sale_order.py @@ -0,0 +1,102 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from datetime import datetime + +from dateutil.relativedelta import relativedelta + +from odoo import api, models +from odoo.osv.expression import OR + +from odoo.addons.queue_job.job import job + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + @api.model + def cron_generate_monthly_invoices(self): + """Cron called daily to check if monthly invoicing needs to be done.""" + company_ids = self._company_monthly_invoicing_today() + if company_ids: + self.generate_monthly_invoices(company_ids) + + @api.model + def generate_monthly_invoices(self, companies=None): + """Generate monthly invoices for customers who require that mode.""" + if not companies: + companies = self.company_id + partner_ids = self.read_group( + [ + ("invoicing_mode", "=", "monthly"), + ("invoice_status", "=", "to invoice"), + ("company_id", "in", companies.ids), + ], + ["partner_invoice_id"], + groupby=["partner_invoice_id"], + ) + for partner in partner_ids: + self.with_delay()._generate_invoices_by_partner( + partner["partner_invoice_id"][0] + ) + companies.write({"invoicing_mode_monthly_last_execution": datetime.now()}) + return partner_ids + + @job(default_channel="root.invoice_monthly") + def _generate_invoices_by_partner(self, partner_id, invoicing_mode="monthly"): + """Generate invoices for a customer sales order.""" + partner = self.env["res.partner"].browse(partner_id) + if partner.invoicing_mode != invoicing_mode: + return "Customer {} is not configured for monthly invoicing.".format( + partner.name + ) + sales = self.search( + [ + ("invoice_status", "=", "to invoice"), + ("partner_invoice_id", "=", partner.id), + ("order_line.qty_to_invoice", "!=", 0), + ] + ) + # By default grouped by partner/currency. Refund are not generated + invoices = sales._create_invoices( + grouped=partner.one_invoice_per_order, final=True + ) + for invoice in invoices: + invoice.with_delay()._validate_invoice() + return invoices + + @api.model + def _company_monthly_invoicing_today(self): + """Get company ids for which today is monthly invoicing day.""" + today = datetime.now() + first_day_this_month = today.replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + first_day_last_month = first_day_this_month - relativedelta(months=1) + last_day_of_this_month = (today + relativedelta(day=31)).day + # Last month still not executed, it needs to be done + domain_last_month = [ + ("invoicing_mode_monthly_last_execution", "<", first_day_last_month), + ] + # Invoicing day is today or in the past and invoicing not yet done + domain_this_month = [ + "|", + ("invoicing_mode_monthly_last_execution", "<", first_day_this_month), + ("invoicing_mode_monthly_last_execution", "=", False), + ("invoicing_mode_monthly_day_todo", "<=", today.day), + ] + # Make sure non exisiting days are done at the end of the month + domain_last_day_of_month = [ + "|", + ("invoicing_mode_monthly_last_execution", "<", first_day_this_month), + ("invoicing_mode_monthly_last_execution", "=", False), + ("invoicing_mode_monthly_day_todo", ">", today.day), + ] + if today.day == last_day_of_this_month: + domain = OR( + [domain_last_month, domain_this_month, domain_last_day_of_month] + ) + else: + domain = OR([domain_last_month, domain_this_month]) + + return self.env["res.company"].search(domain) diff --git a/account_invoice_mode_monthly/readme/CONTRIBUTORS.rst b/account_invoice_mode_monthly/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..b180cbde2b2 --- /dev/null +++ b/account_invoice_mode_monthly/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Camptocamp `_: + + * Thierry Ducrest diff --git a/account_invoice_mode_monthly/readme/DESCRIPTION.rst b/account_invoice_mode_monthly/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..4e45ab48a76 --- /dev/null +++ b/account_invoice_mode_monthly/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This module allows to select a monthly invoicing mode for a customer. +It is based on `account_invoice_base_invoicing_mode`. +When this mode is selected for a customer, the customer will be automatically +invoiced diff --git a/account_invoice_mode_monthly/readme/INSTALL.rst b/account_invoice_mode_monthly/readme/INSTALL.rst new file mode 100644 index 00000000000..e69de29bb2d diff --git a/account_invoice_mode_monthly/tests/__init__.py b/account_invoice_mode_monthly/tests/__init__.py new file mode 100644 index 00000000000..bae1b469a28 --- /dev/null +++ b/account_invoice_mode_monthly/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_invoice_mode_monthly +from . import test_invoice_mode_monthly_is_it_today diff --git a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py new file mode 100644 index 00000000000..309b52a8f9a --- /dev/null +++ b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py @@ -0,0 +1,138 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import tools +from odoo.tests.common import SavepointCase + + +class TestInvoiceModeMonthly(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.SaleOrder = cls.env["sale.order"] + cls.partner = cls.env.ref("base.res_partner_1") + cls.partner.invoicing_mode = "monthly" + cls.partner2 = cls.env.ref("base.res_partner_2") + cls.partner2.invoicing_mode = "monthly" + cls.product = cls.env.ref("product.product_delivery_01") + cls.so1 = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "order_line": [ + ( + 0, + 0, + { + "name": "Line one", + "product_id": cls.product.id, + "product_uom_qty": 4, + "product_uom": cls.product.uom_id.id, + "price_unit": 123, + }, + ) + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + + cls.so2 = cls.env["sale.order"].create( + { + "partner_id": cls.partner.id, + "partner_invoice_id": cls.partner.id, + "partner_shipping_id": cls.partner.id, + "order_line": [ + ( + 0, + 0, + { + "name": "Line one", + "product_id": cls.product.id, + "product_uom_qty": 4, + "product_uom": cls.product.uom_id.id, + "price_unit": 123, + }, + ) + ], + "pricelist_id": cls.env.ref("product.list0").id, + } + ) + cls.company = cls.so1.company_id + + stock_location = cls.env.ref("stock.stock_location_stock") + inventory = cls.env["stock.inventory"].create( + { + "name": "Test Inventory", + "product_ids": [(6, 0, cls.product.ids)], + "state": "confirm", + "line_ids": [ + ( + 0, + 0, + { + "product_qty": 100, + "location_id": stock_location.id, + "product_id": cls.product.id, + "product_uom_id": cls.product.uom_id.id, + }, + ) + ], + } + ) + inventory.action_validate() + + def deliver_invoice(self, sale_order): + sale_order.action_confirm() + for picking in sale_order.picking_ids: + for line in picking.move_lines: + line.quantity_done = line.product_uom_qty + picking.action_assign() + picking.button_validate() + + def test_saleorder_grouped_in_invoice(self): + """Check multiple sale order grouped in one invoice""" + self.deliver_invoice(self.so1) + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + self.assertEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + + def test_split_invoice_by_sale_order(self): + """For same customer invoice 2 sales order separately.""" + self.partner.invoicing_mode = "monthly" + self.partner.one_invoice_per_order = True + self.deliver_invoice(self.so1) + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + self.assertNotEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + self.assertEqual(self.so2.invoice_ids.state, "posted") + + def test_invoice_for_multiple_customer(self): + """Check two sale order for different customers.""" + self.partner.invoicing_mode = "monthly" + self.so2.partner_id = self.partner2 + self.so2.partner_invoice_id = self.partner2 + self.so2.partner_shipping_id = self.partner2 + self.deliver_invoice(self.so1) + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + self.assertNotEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + self.assertEqual(self.so2.invoice_ids.state, "posted") diff --git a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly_is_it_today.py b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly_is_it_today.py new file mode 100644 index 00000000000..d978d4336ef --- /dev/null +++ b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly_is_it_today.py @@ -0,0 +1,63 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from freezegun import freeze_time + +from odoo.tests.common import SavepointCase + + +class TestInvoiceModeMonthly(SavepointCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.company + cls.SaleOrder = cls.env["sale.order"] + + def test_late_invoicing_for_last_month(self): + """Check that last month invoicing will be done if missed.""" + company = self.env.company + company.invoicing_mode_monthly_day_todo = 31 + company.invoicing_mode_monthly_last_execution = "2020-05-31" + self.assertTrue(self.env.company) + with freeze_time("2020-07-03"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertTrue(res) + company.invoicing_mode_monthly_last_execution = "2020-06-30" + with freeze_time("2020-07-03"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertFalse(res) + + def test_selected_day_not_exist_in_month(self): + """Check on last day of the month invoicing is done. + + The day of invoicing selected could not exist in the current + month, but invoicing should still be executed on the last + day of the month. + """ + company = self.env.company + company.invoicing_mode_monthly_day_todo = 31 + company.invoicing_mode_monthly_last_execution = "2020-05-29" + self.assertTrue(self.env.company) + with freeze_time("2020-06-29"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertFalse(res) + with freeze_time("2020-06-30"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertTrue(res) + + def test_no_invoicing_done_yet(self): + """Check when is the first monthly invoicing done. + + When monthly invoicing has never been done, it will not be run + for the previous month. + """ + company = self.env.company + company.invoicing_mode_monthly_day_todo = 15 + company.invoicing_mode_monthly_last_execution = None + self.assertTrue(self.env.company) + with freeze_time("2020-06-11"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertFalse(res) + with freeze_time("2020-06-30"): + res = self.SaleOrder._company_monthly_invoicing_today() + self.assertTrue(res) diff --git a/account_invoice_mode_monthly/views/res_config_settings_views.xml b/account_invoice_mode_monthly/views/res_config_settings_views.xml new file mode 100644 index 00000000000..dbe09556648 --- /dev/null +++ b/account_invoice_mode_monthly/views/res_config_settings_views.xml @@ -0,0 +1,43 @@ + + + + + res.config.settings + +
+

Invoicing Mode

+
+
+
+
+ Monthly Invoicing Options +
+
+
+
+
+
+
+
+
+
+ + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000000..881bd9f2643 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +freezegun diff --git a/setup/account_invoice_base_invoicing_mode/odoo/addons/account_invoice_base_invoicing_mode b/setup/account_invoice_base_invoicing_mode/odoo/addons/account_invoice_base_invoicing_mode new file mode 120000 index 00000000000..d28ad4fff5f --- /dev/null +++ b/setup/account_invoice_base_invoicing_mode/odoo/addons/account_invoice_base_invoicing_mode @@ -0,0 +1 @@ +../../../../account_invoice_base_invoicing_mode \ No newline at end of file diff --git a/setup/account_invoice_base_invoicing_mode/setup.py b/setup/account_invoice_base_invoicing_mode/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_invoice_base_invoicing_mode/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/account_invoice_mode_at_shipping/odoo/addons/account_invoice_mode_at_shipping b/setup/account_invoice_mode_at_shipping/odoo/addons/account_invoice_mode_at_shipping new file mode 120000 index 00000000000..46c28f4f492 --- /dev/null +++ b/setup/account_invoice_mode_at_shipping/odoo/addons/account_invoice_mode_at_shipping @@ -0,0 +1 @@ +../../../../account_invoice_mode_at_shipping \ No newline at end of file diff --git a/setup/account_invoice_mode_at_shipping/setup.py b/setup/account_invoice_mode_at_shipping/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_invoice_mode_at_shipping/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/account_invoice_mode_monthly/odoo/addons/account_invoice_mode_monthly b/setup/account_invoice_mode_monthly/odoo/addons/account_invoice_mode_monthly new file mode 120000 index 00000000000..ed7e033ae94 --- /dev/null +++ b/setup/account_invoice_mode_monthly/odoo/addons/account_invoice_mode_monthly @@ -0,0 +1 @@ +../../../../account_invoice_mode_monthly \ No newline at end of file diff --git a/setup/account_invoice_mode_monthly/setup.py b/setup/account_invoice_mode_monthly/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/account_invoice_mode_monthly/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 960a346550ad2de3e7190cf83643a4b2d7f639d8 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Fri, 24 Jul 2020 07:57:15 +0200 Subject: [PATCH 2/9] [13.0][IMP] acconut_invoicing_mode group by terms Sales order with different payment term are not grouped into the same invoice anymore. This grouping can be easily changed by overriding the coresponding method. During this change the parameter of the job generating the invoices has changes and is now the sale_order_ids to process. --- .../models/sale_order.py | 50 ++++++++++--------- .../tests/test_invoice_mode_monthly.py | 24 ++++++++- 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/account_invoice_mode_monthly/models/sale_order.py b/account_invoice_mode_monthly/models/sale_order.py index 70758847e4f..dc104fa7868 100644 --- a/account_invoice_mode_monthly/models/sale_order.py +++ b/account_invoice_mode_monthly/models/sale_order.py @@ -23,43 +23,47 @@ def cron_generate_monthly_invoices(self): @api.model def generate_monthly_invoices(self, companies=None): - """Generate monthly invoices for customers who require that mode.""" + """Generate monthly invoices for customers who require that mode. + + Invoices will be generated by other jobs split for different customer + and different payment term. + """ if not companies: companies = self.company_id - partner_ids = self.read_group( + saleorder_groups = self.read_group( [ ("invoicing_mode", "=", "monthly"), ("invoice_status", "=", "to invoice"), ("company_id", "in", companies.ids), ], ["partner_invoice_id"], - groupby=["partner_invoice_id"], + groupby=self._get_groupby_fields_for_monthly_invoicing(), + lazy=False, ) - for partner in partner_ids: - self.with_delay()._generate_invoices_by_partner( - partner["partner_invoice_id"][0] - ) + for saleorder_group in saleorder_groups: + saleorder_ids = self.search(saleorder_group["__domain"]).ids + self.with_delay()._generate_invoices_by_partner(saleorder_ids) companies.write({"invoicing_mode_monthly_last_execution": datetime.now()}) - return partner_ids + return saleorder_groups + + @api.model + def _get_groupby_fields_for_monthly_invoicing(self): + """Returns the sale order fields used to group them into jobs.""" + return ["partner_invoice_id", "payment_term_id"] @job(default_channel="root.invoice_monthly") - def _generate_invoices_by_partner(self, partner_id, invoicing_mode="monthly"): - """Generate invoices for a customer sales order.""" - partner = self.env["res.partner"].browse(partner_id) - if partner.invoicing_mode != invoicing_mode: - return "Customer {} is not configured for monthly invoicing.".format( - partner.name - ) - sales = self.search( - [ - ("invoice_status", "=", "to invoice"), - ("partner_invoice_id", "=", partner.id), - ("order_line.qty_to_invoice", "!=", 0), - ] + def _generate_invoices_by_partner(self, saleorder_ids, invoicing_mode="monthly"): + """Generate invoices for a group of sale order belonging to a customer. + """ + sales = ( + self.browse(saleorder_ids) + .exists() + .filtered(lambda r: r.invoice_status == "to invoice") ) - # By default grouped by partner/currency. Refund are not generated + if not sales: + return "No sale order found to invoice ?" invoices = sales._create_invoices( - grouped=partner.one_invoice_per_order, final=True + grouped=sales[0].partner_invoice_id.one_invoice_per_order, final=True ) for invoice in invoices: invoice.with_delay()._validate_invoice() diff --git a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py index 309b52a8f9a..949fb35bf11 100644 --- a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py +++ b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py @@ -15,11 +15,14 @@ def setUpClass(cls): cls.partner2 = cls.env.ref("base.res_partner_2") cls.partner2.invoicing_mode = "monthly" cls.product = cls.env.ref("product.product_delivery_01") + cls.pt1 = cls.env["account.payment.term"].create({"name": "Term Two"}) + cls.pt2 = cls.env["account.payment.term"].create({"name": "Term One"}) cls.so1 = cls.env["sale.order"].create( { "partner_id": cls.partner.id, "partner_invoice_id": cls.partner.id, "partner_shipping_id": cls.partner.id, + "payment_term_id": cls.pt1.id, "order_line": [ ( 0, @@ -36,12 +39,13 @@ def setUpClass(cls): "pricelist_id": cls.env.ref("product.list0").id, } ) - + # Lets give the saleorder the same partner and payment terms cls.so2 = cls.env["sale.order"].create( { "partner_id": cls.partner.id, "partner_invoice_id": cls.partner.id, "partner_shipping_id": cls.partner.id, + "payment_term_id": cls.pt1.id, "order_line": [ ( 0, @@ -90,6 +94,22 @@ def deliver_invoice(self, sale_order): picking.action_assign() picking.button_validate() + def test_saleorder_with_different_mode_term(self): + """Check multiple sale order one partner diverse terms.""" + self.so1.payment_term_id = self.pt1.id + self.deliver_invoice(self.so1) + self.so2.payment_term_id = self.pt2.id + self.deliver_invoice(self.so2) + with tools.mute_logger("odoo.addons.queue_job.models.base"): + self.SaleOrder.with_context( + test_queue_job_no_delay=True + ).generate_monthly_invoices(self.company) + self.assertEqual(len(self.so1.invoice_ids), 1) + self.assertEqual(len(self.so2.invoice_ids), 1) + # Two invoices because the term are different + self.assertNotEqual(self.so1.invoice_ids, self.so2.invoice_ids) + self.assertEqual(self.so1.invoice_ids.state, "posted") + def test_saleorder_grouped_in_invoice(self): """Check multiple sale order grouped in one invoice""" self.deliver_invoice(self.so1) @@ -100,6 +120,7 @@ def test_saleorder_grouped_in_invoice(self): ).generate_monthly_invoices(self.company) self.assertEqual(len(self.so1.invoice_ids), 1) self.assertEqual(len(self.so2.invoice_ids), 1) + # Same invoice for both order self.assertEqual(self.so1.invoice_ids, self.so2.invoice_ids) self.assertEqual(self.so1.invoice_ids.state, "posted") @@ -115,6 +136,7 @@ def test_split_invoice_by_sale_order(self): ).generate_monthly_invoices(self.company) self.assertEqual(len(self.so1.invoice_ids), 1) self.assertEqual(len(self.so2.invoice_ids), 1) + # Two invoices as they must be split self.assertNotEqual(self.so1.invoice_ids, self.so2.invoice_ids) self.assertEqual(self.so1.invoice_ids.state, "posted") self.assertEqual(self.so2.invoice_ids.state, "posted") From 2ef66764fe30a0d8c8c8df4603eeb7b95622efd2 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 9 Sep 2020 08:31:17 +0200 Subject: [PATCH 3/9] Invoice sales based on moves If we group several sales in the same procurement group, we'll invoice only one sale order. Tracing back to the sales through the stock moves ensure we invoice all of them. --- account_invoice_mode_at_shipping/models/stock_picking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/account_invoice_mode_at_shipping/models/stock_picking.py b/account_invoice_mode_at_shipping/models/stock_picking.py index 3defcf213cf..45289dd0912 100644 --- a/account_invoice_mode_at_shipping/models/stock_picking.py +++ b/account_invoice_mode_at_shipping/models/stock_picking.py @@ -32,4 +32,4 @@ def _invoicing_at_shipping(self): return invoices def _get_sales_order_to_invoice(self): - return self.mapped("move_lines.group_id.sale_id") + return self.mapped("move_lines.sale_line_id.order_id") From 2525a0a825406580b08349c91166f1d3176dfd01 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 9 Sep 2020 08:32:31 +0200 Subject: [PATCH 4/9] Ensure _get_related_invoices include all the related invoices Through the stock moves --- .../models/__init__.py | 1 + .../models/stock_move.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 account_invoice_mode_at_shipping/models/stock_move.py diff --git a/account_invoice_mode_at_shipping/models/__init__.py b/account_invoice_mode_at_shipping/models/__init__.py index 548fc596ae1..b87ddbd73f5 100644 --- a/account_invoice_mode_at_shipping/models/__init__.py +++ b/account_invoice_mode_at_shipping/models/__init__.py @@ -1,2 +1,3 @@ from . import res_partner +from . import stock_move from . import stock_picking diff --git a/account_invoice_mode_at_shipping/models/stock_move.py b/account_invoice_mode_at_shipping/models/stock_move.py new file mode 100644 index 00000000000..a9f8f1ba041 --- /dev/null +++ b/account_invoice_mode_at_shipping/models/stock_move.py @@ -0,0 +1,19 @@ +# Copyright 2020 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _get_related_invoices(self): + """ Overridden from stock_account to return the customer invoices + related to this stock move. + """ + invoices = super()._get_related_invoices() + line_invoices = self.mapped("sale_line_id.order_id.invoice_ids").filtered( + lambda x: x.state == "posted" + ) + invoices |= line_invoices + return invoices From 5ad9ef5cfb7bb60df1cab3eea30d3ad7a19baef4 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 22 Sep 2020 11:39:44 +0200 Subject: [PATCH 5/9] [13.0][FIX] Add acconut_invoicing_mode Add few changes from reviews. Use sudo to post the invoice. --- .../models/account_invoice.py | 2 +- .../models/sale_order.py | 4 +-- .../models/stock_picking.py | 26 +++++++++++-------- .../tests/test_invoice_mode_at_shipping.py | 3 ++- .../tests/test_invoice_mode_monthly.py | 1 + 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/account_invoice_base_invoicing_mode/models/account_invoice.py b/account_invoice_base_invoicing_mode/models/account_invoice.py index ff4dd27039c..7e548bf20ad 100644 --- a/account_invoice_base_invoicing_mode/models/account_invoice.py +++ b/account_invoice_base_invoicing_mode/models/account_invoice.py @@ -12,4 +12,4 @@ class AccountMove(models.Model): @job(default_channel="root.invoice_validation") @related_action(action="related_action_open_invoice") def _validate_invoice(self): - return self.action_post() + return self.sudo().action_post() diff --git a/account_invoice_base_invoicing_mode/models/sale_order.py b/account_invoice_base_invoicing_mode/models/sale_order.py index f22ee4cfefa..aa0f5832296 100644 --- a/account_invoice_base_invoicing_mode/models/sale_order.py +++ b/account_invoice_base_invoicing_mode/models/sale_order.py @@ -7,6 +7,4 @@ class SaleOrder(models.Model): _inherit = "sale.order" - invoicing_mode = fields.Selection( - related="partner_invoice_id.invoicing_mode", readonly=True - ) + invoicing_mode = fields.Selection(related="partner_invoice_id.invoicing_mode") diff --git a/account_invoice_mode_at_shipping/models/stock_picking.py b/account_invoice_mode_at_shipping/models/stock_picking.py index 45289dd0912..24a2c8a9e26 100644 --- a/account_invoice_mode_at_shipping/models/stock_picking.py +++ b/account_invoice_mode_at_shipping/models/stock_picking.py @@ -11,22 +11,26 @@ class StockPicking(models.Model): def action_done(self): res = super().action_done() - picking_to_invoice = self.filtered( - lambda r: r.sale_id.partner_invoice_id.invoicing_mode == "at_shipping" - and r.picking_type_code == "outgoing" - ) - for picking in picking_to_invoice: - picking.with_delay()._invoicing_at_shipping() + + for picking in self: + if picking._invoice_at_shipping(): + picking.with_delay()._invoicing_at_shipping() return res + def _invoice_at_shipping(self): + """Check if picking must be invoiced at shipping.""" + self.ensure_one() + return ( + self.picking_type_code == "outgoing" + and self.sale_id.partner_invoice_id.invoicing_mode == "at_shipping" + ) + @job(default_channel="root.invoice_at_shipping") def _invoicing_at_shipping(self): self.ensure_one() - sale_order_ids = self._get_sales_order_to_invoice() - partner = sale_order_ids.partner_invoice_id - invoices = sale_order_ids._create_invoices( - grouped=partner.one_invoice_per_order - ) + sales_order = self._get_sales_order_to_invoice() + partner = sales_order.partner_invoice_id + invoices = sales_order._create_invoices(grouped=partner.one_invoice_per_order) for invoice in invoices: invoice.with_delay()._validate_invoice() return invoices diff --git a/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py b/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py index f93f4702186..4466513d65e 100644 --- a/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py +++ b/account_invoice_mode_at_shipping/tests/test_invoice_mode_at_shipping.py @@ -8,6 +8,7 @@ class TestInvoiceModeAtShipping(SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.partner = cls.env.ref("base.res_partner_1") cls.product = cls.env.ref("product.product_delivery_01") cls.so1 = cls.env["sale.order"].create( @@ -66,7 +67,7 @@ def test_invoice_created_at_shipping(self): self.assertEqual(self.so1.invoice_ids.state, "posted") def test_invoice_not_created_at_shipping(self): - """Check that an invoice is created when goods are shipped.""" + """Check that an invoice is not created when goods are shipped.""" self.partner.invoicing_mode = "standard" self.so1.action_confirm() for picking in self.so1.picking_ids: diff --git a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py index 949fb35bf11..1096cbd59c9 100644 --- a/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py +++ b/account_invoice_mode_monthly/tests/test_invoice_mode_monthly.py @@ -9,6 +9,7 @@ class TestInvoiceModeMonthly(SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.SaleOrder = cls.env["sale.order"] cls.partner = cls.env.ref("base.res_partner_1") cls.partner.invoicing_mode = "monthly" From a070a3a6d57d803b119b241ae430913cb6e9f2d9 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 27 Apr 2021 06:59:54 +0200 Subject: [PATCH 6/9] [13.0][FIX] account_invoicing_mode_at_shipping Split invoice creation on invoice partner settings that decides if sales order atre being grouped in invoice or not. The previous implementation would crash. --- .../__init__.py | 1 - .../tests/__init__.py | 1 - .../tests/test_base_invoicing_mode.py | 11 ----------- .../models/stock_picking.py | 18 ++++++++++++++---- 4 files changed, 14 insertions(+), 17 deletions(-) delete mode 100644 account_invoice_base_invoicing_mode/tests/__init__.py delete mode 100644 account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py diff --git a/account_invoice_base_invoicing_mode/__init__.py b/account_invoice_base_invoicing_mode/__init__.py index 0ee8b5073e2..0650744f6bc 100644 --- a/account_invoice_base_invoicing_mode/__init__.py +++ b/account_invoice_base_invoicing_mode/__init__.py @@ -1,2 +1 @@ from . import models -from . import tests diff --git a/account_invoice_base_invoicing_mode/tests/__init__.py b/account_invoice_base_invoicing_mode/tests/__init__.py deleted file mode 100644 index d7710a099a2..00000000000 --- a/account_invoice_base_invoicing_mode/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import test_base_invoicing_mode diff --git a/account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py b/account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py deleted file mode 100644 index 5e0bf6f9866..00000000000 --- a/account_invoice_base_invoicing_mode/tests/test_base_invoicing_mode.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright 2020 Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) - - -from odoo.tests.common import SavepointCase - - -class TestBaseInvoicingMode(SavepointCase): - @classmethod - def setUpClass(cls): - super().setUpClass() diff --git a/account_invoice_mode_at_shipping/models/stock_picking.py b/account_invoice_mode_at_shipping/models/stock_picking.py index 24a2c8a9e26..f76d2c11b68 100644 --- a/account_invoice_mode_at_shipping/models/stock_picking.py +++ b/account_invoice_mode_at_shipping/models/stock_picking.py @@ -28,12 +28,22 @@ def _invoice_at_shipping(self): @job(default_channel="root.invoice_at_shipping") def _invoicing_at_shipping(self): self.ensure_one() - sales_order = self._get_sales_order_to_invoice() - partner = sales_order.partner_invoice_id - invoices = sales_order._create_invoices(grouped=partner.one_invoice_per_order) + sales = self._get_sales_order_to_invoice() + # Split invoice creation on partner sales grouping on invoice settings + sales_one_invoice_per_order = sales.filtered( + "partner_invoice_id.one_invoice_per_order" + ) + invoices = self.env["account.move"].browse() + if sales_one_invoice_per_order: + invoices |= sales_one_invoice_per_order._create_invoices(grouped=True) + sales_many_invoice_per_order = sales - sales_one_invoice_per_order + if sales_many_invoice_per_order: + invoices |= sales_many_invoice_per_order._create_invoices(grouped=False) for invoice in invoices: invoice.with_delay()._validate_invoice() return invoices def _get_sales_order_to_invoice(self): - return self.mapped("move_lines.sale_line_id.order_id") + return self.mapped("move_lines.sale_line_id.order_id").filtered( + lambda r: r._get_invoiceable_lines() + ) From dcf58360d8286e081432b076cecba5b893b7eac3 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 27 Apr 2021 08:23:12 +0200 Subject: [PATCH 7/9] [13.0][FIX] account_invoicing_mode_at_shipping Filter out sales order that have nothing to invoice. To avoid an exception at invoice createion. --- account_invoice_mode_at_shipping/models/stock_picking.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/account_invoice_mode_at_shipping/models/stock_picking.py b/account_invoice_mode_at_shipping/models/stock_picking.py index f76d2c11b68..157344b87f7 100644 --- a/account_invoice_mode_at_shipping/models/stock_picking.py +++ b/account_invoice_mode_at_shipping/models/stock_picking.py @@ -28,7 +28,11 @@ def _invoice_at_shipping(self): @job(default_channel="root.invoice_at_shipping") def _invoicing_at_shipping(self): self.ensure_one() - sales = self._get_sales_order_to_invoice() + sales = self.env["sale.order"].browse() + # Filter out non invoicable sales order + for sale in self._get_sales_order_to_invoice(): + if sale._get_invoiceable_lines(): + sales |= sale # Split invoice creation on partner sales grouping on invoice settings sales_one_invoice_per_order = sales.filtered( "partner_invoice_id.one_invoice_per_order" From e0fe3299924049ae72153226e3e2e1a98fcd244d Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 27 Apr 2021 08:51:45 +0200 Subject: [PATCH 8/9] [13.0][IMP] account_invoicing_mode_at_shipping Add explicit message if nothing is invoiced --- account_invoice_mode_at_shipping/models/stock_picking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/account_invoice_mode_at_shipping/models/stock_picking.py b/account_invoice_mode_at_shipping/models/stock_picking.py index 157344b87f7..84b6d67b17a 100644 --- a/account_invoice_mode_at_shipping/models/stock_picking.py +++ b/account_invoice_mode_at_shipping/models/stock_picking.py @@ -1,7 +1,7 @@ # Copyright 2020 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) -from odoo import models +from odoo import _, models from odoo.addons.queue_job.job import job @@ -45,7 +45,7 @@ def _invoicing_at_shipping(self): invoices |= sales_many_invoice_per_order._create_invoices(grouped=False) for invoice in invoices: invoice.with_delay()._validate_invoice() - return invoices + return invoices or _("Nothing to invoice.") def _get_sales_order_to_invoice(self): return self.mapped("move_lines.sale_line_id.order_id").filtered( From f15e7dee3036e3f15030ecafe57b7f81f2e7f6fe Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Wed, 28 Jul 2021 10:09:53 +0200 Subject: [PATCH 9/9] [13.0] account-invoicing-mode add roadmap --- account_invoice_base_invoicing_mode/readme/ROADMAP.rst | 7 +++++++ account_invoice_mode_monthly/readme/ROADMAP.rst | 5 +++++ 2 files changed, 12 insertions(+) create mode 100644 account_invoice_base_invoicing_mode/readme/ROADMAP.rst create mode 100644 account_invoice_mode_monthly/readme/ROADMAP.rst diff --git a/account_invoice_base_invoicing_mode/readme/ROADMAP.rst b/account_invoice_base_invoicing_mode/readme/ROADMAP.rst new file mode 100644 index 00000000000..170d52b69c2 --- /dev/null +++ b/account_invoice_base_invoicing_mode/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +On the version 13.0 PR it has been discussed to rename the modules like this: + + * account_invoice_base_invoicing_mode -> partner_invoicing_mode + * account_invoice_mode_monthly -> partner_invoicing_mode_monthly + * account_invoice_mode_at_shipping -> partner_invoicing_mode_at_shipping + +It would be great to do it, during the version 14.0 migration. diff --git a/account_invoice_mode_monthly/readme/ROADMAP.rst b/account_invoice_mode_monthly/readme/ROADMAP.rst new file mode 100644 index 00000000000..ec704cfa7ac --- /dev/null +++ b/account_invoice_mode_monthly/readme/ROADMAP.rst @@ -0,0 +1,5 @@ +On the version 13.0 PR there is some discussion about changing how the +`_company_monthly_invoicing_today` works by storing the next invoicing day +to simplify the computation. +Would it be an improvment or not ? +I answered with this `comment `_