diff --git a/account_credit_control/README.rst b/account_credit_control/README.rst index f37b10d2d..f6d134dcd 100644 --- a/account_credit_control/README.rst +++ b/account_credit_control/README.rst @@ -114,7 +114,11 @@ Contributors - Akim Juillerat (Camptocamp) - Kinner Vachhani (Access Bookings Ltd) - Raf Ven -- Quentin Groulard (ACSONE) +- `Acsone `__: + + - Quentin Groulard + - Yannick Payot + - `Tecnativa `__: - Vicent Cubells diff --git a/account_credit_control/models/credit_control_communication.py b/account_credit_control/models/credit_control_communication.py index 00ca84288..435e53844 100644 --- a/account_credit_control/models/credit_control_communication.py +++ b/account_credit_control/models/credit_control_communication.py @@ -3,6 +3,7 @@ # Copyright 2018 Access Bookings Ltd (https://accessbookings.com) # Copyright 2020 Manuel Calero - Tecnativa # Copyright 2023 Tecnativa - Víctor Martínez +# Copyright 2019 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import threading @@ -114,61 +115,74 @@ def _get_credit_lines( ): """Return credit lines related to a partner and a policy level""" cr_line_obj = self.env["credit.control.line"] - cr_lines = cr_line_obj.search( - [ - ("id", "in", line_ids), - ("partner_id", "=", partner_id), - ("policy_level_id", "=", level_id), - ("currency_id", "=", currency_id), - ("company_id", "=", company_id), - ] + domain = [ + ("id", "in", line_ids), + ("partner_id", "=", partner_id), + ("currency_id", "=", currency_id), + ("company_id", "=", company_id), + ("policy_level_id", "=", level_id), + ] + return cr_line_obj.search(domain, order="level DESC") + + @api.model + def _sql_credit_lines_groups(self): + """Create a query to return: + partner, level, currency, company + """ + return ( + "SELECT DISTINCT" + " partner_id," + " policy_level_id," + " policy_level.level," + " line.currency_id," + " line.company_id" + " FROM credit_control_line AS line" + " JOIN credit_control_policy_level as policy_level" + " ON (line.policy_level_id = policy_level.id)" + " WHERE line.id in %s" + " ORDER BY policy_level.level, line.currency_id" ) - return cr_lines @api.model - def _aggregate_credit_lines(self, lines): - """Aggregate credit control line by partner, level, and currency""" - if not lines: - return [] + def _get_credit_line_groups(self, lines): + """Returns key groups to be processed""" # Needed for related stored fields # are recomputed before executing the SQL lines.flush_recordset() - sql = ( - "SELECT distinct partner_id, policy_level_id, " - " credit_control_line.currency_id, " - " credit_control_policy_level.level, " - " credit_control_line.company_id " - " FROM credit_control_line JOIN credit_control_policy_level " - " ON (credit_control_line.policy_level_id = " - " credit_control_policy_level.id)" - " WHERE credit_control_line.id in %s" - " ORDER by credit_control_policy_level.level, " - " credit_control_line.currency_id" - ) + sql = self._sql_credit_lines_groups() cr = self.env.cr cr.execute(sql, (tuple(lines.ids),)) - res = cr.dictfetchall() + return cr.dictfetchall() + + @api.model + def _prepare_communication_data(self, cr_lines): + line = cr_lines[0] + company = line.company_id or self.env.company + return { + "credit_control_line_ids": [(6, 0, cr_lines.ids)], + "partner_id": line.partner_id.id, + "policy_level_id": line.policy_level_id.id, + "currency_id": line.currency_id.id or company.currency_id.id, + "company_id": company.id, + } + + @api.model + def _aggregate_credit_lines(self, lines): + """Aggregate credit control line by partner, level, and currency""" + if not lines: + return [] datas = [] - for group in res: - data = {} - level_lines = self._get_credit_lines( + for group in self._get_credit_line_groups(lines): + grouped_lines = self._get_credit_lines( lines.ids, group["partner_id"], group["policy_level_id"], group["currency_id"], group["company_id"], ) - company = ( - self.env["res.company"].browse(group["company_id"]) - if group["company_id"] - else self.env.company - ) - company_currency = company.currency_id - data["credit_control_line_ids"] = [(6, 0, level_lines.ids)] - data["partner_id"] = group["partner_id"] - data["policy_level_id"] = group["policy_level_id"] - data["currency_id"] = group["currency_id"] or company_currency.id - data["company_id"] = group["company_id"] or company.id + if not grouped_lines: + continue + data = self._prepare_communication_data(grouped_lines) datas.append(data) return datas diff --git a/account_credit_control/models/credit_control_line.py b/account_credit_control/models/credit_control_line.py index 6099afa57..4308e0f23 100644 --- a/account_credit_control/models/credit_control_line.py +++ b/account_credit_control/models/credit_control_line.py @@ -71,7 +71,7 @@ class CreditControlLine(models.Model): partner_id = fields.Many2one( comodel_name="res.partner", required=True, - readonly=False, + index=True, ) commercial_partner_id = fields.Many2one( comodel_name="res.partner", @@ -116,6 +116,7 @@ class CreditControlLine(models.Model): policy_id = fields.Many2one( comodel_name="credit.control.policy", related="policy_level_id.policy_id", + index=True, store=True, ) level = fields.Integer( diff --git a/account_credit_control/readme/CONTRIBUTORS.md b/account_credit_control/readme/CONTRIBUTORS.md index dbc867823..2fa812891 100644 --- a/account_credit_control/readme/CONTRIBUTORS.md +++ b/account_credit_control/readme/CONTRIBUTORS.md @@ -4,7 +4,9 @@ - Akim Juillerat (Camptocamp) \<\> - Kinner Vachhani (Access Bookings Ltd) \<\> - Raf Ven \<\> -- Quentin Groulard (ACSONE) \<\> +- [Acsone](https://www.acsone.eu): + - Quentin Groulard + - Yannick Payot - [Tecnativa](https://www.tecnativa.com): - Vicent Cubells - Manuel Calero diff --git a/account_credit_control/static/description/index.html b/account_credit_control/static/description/index.html index e7dfef6a3..91746329c 100644 --- a/account_credit_control/static/description/index.html +++ b/account_credit_control/static/description/index.html @@ -454,7 +454,11 @@

Contributors

  • Akim Juillerat (Camptocamp) <akim.juillerat@camptocamp.com>
  • Kinner Vachhani (Access Bookings Ltd) <kin.vachhani@gmail.com>
  • Raf Ven <raf.ven@dynapps.be>
  • -
  • Quentin Groulard (ACSONE) <quentin.groulard@acsone.eu>
  • +
  • Acsone:
      +
    • Quentin Groulard
    • +
    • Yannick Payot
    • +
    +
  • Tecnativa:
    • Vicent Cubells
    • Manuel Calero
    • diff --git a/account_credit_control/wizard/credit_control_printer.py b/account_credit_control/wizard/credit_control_printer.py index b8b0185cf..ad8f9015e 100644 --- a/account_credit_control/wizard/credit_control_printer.py +++ b/account_credit_control/wizard/credit_control_printer.py @@ -19,7 +19,7 @@ def _default_line_ids(self): context = self.env.context if context.get("active_model") != "credit.control.line": return False - return context.get("active_ids", False) + return context.get("active_ids") mark_as_sent = fields.Boolean( string="Mark letter lines as done", diff --git a/account_credit_control_aggregate_level/README.rst b/account_credit_control_aggregate_level/README.rst new file mode 100644 index 000000000..610430b0b --- /dev/null +++ b/account_credit_control_aggregate_level/README.rst @@ -0,0 +1,102 @@ +====================== +Account Credit Control +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:abaee855dd44ae136c041103b894cef9609b42a63c31df8f9da726b5caabb0d7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcredit--control-lightgray.png?logo=github + :target: https://github.com/OCA/credit-control/tree/18.0/account_credit_control_aggregate_level + :alt: OCA/credit-control +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/credit-control-18-0/credit-control-18-0-account_credit_control_aggregate_level + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/credit-control&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Account Credit Control module is a part of Financial Tools used in +business to ensure that once sales are made they are realised as cash. +This module helps to identify outstanding debt beyond tolerance level +and setup followup method. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +On credit policy enable ``Aggregate lower levels`` to aggregate lines +from lower level and same partner when a credit control line generated +by this policy is processed. + +Usage +===== + +The option ``Aggregate lower levels`` on ``Credit Control Policy`` helps +to manage high credit control line generation rates (e.g. if you are +selling monthly subscriptions). In such situation you may want to avoid +spamming reminders to customers with several credit control lines of +different levels, and only take action for the highest level. Thus, a +communication is sent for a high level will also contain the lower level +lines. Plus, you can use the filter ``Group Lines`` in the +``Credit Control Lines`` menu to hide lines that will be auto-processed +when a highest level line is. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +- `Acsone `__: + + - Quentin Groulard + - Yannick Payot + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/credit-control `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/account_credit_control_aggregate_level/__init__.py b/account_credit_control_aggregate_level/__init__.py new file mode 100644 index 000000000..d6c56a95f --- /dev/null +++ b/account_credit_control_aggregate_level/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import models +from . import wizard diff --git a/account_credit_control_aggregate_level/__manifest__.py b/account_credit_control_aggregate_level/__manifest__.py new file mode 100644 index 000000000..78b48e04d --- /dev/null +++ b/account_credit_control_aggregate_level/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Account Credit Control", + "version": "18.0.1.0.0", + "author": "ACSONE SA/NV, Odoo Community Association (OCA)", + "maintainer": "ACSONE SA/NV", + "category": "Finance", + "depends": ["account_credit_control"], + "website": "https://github.com/OCA/credit-control", + "data": [ + # Views + "views/credit_control_line.xml", + "views/credit_control_policy.xml", + ], + "demo": [], + "installable": True, + "license": "AGPL-3", + "application": True, +} diff --git a/account_credit_control_aggregate_level/models/__init__.py b/account_credit_control_aggregate_level/models/__init__.py new file mode 100644 index 000000000..8ce9ff467 --- /dev/null +++ b/account_credit_control_aggregate_level/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import credit_control_communication +from . import credit_control_line +from . import credit_control_policy diff --git a/account_credit_control_aggregate_level/models/credit_control_communication.py b/account_credit_control_aggregate_level/models/credit_control_communication.py new file mode 100644 index 000000000..f8bbcfc9c --- /dev/null +++ b/account_credit_control_aggregate_level/models/credit_control_communication.py @@ -0,0 +1,56 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models + + +class CreditControlCommunication(models.Model): + _inherit = "credit.control.communication" + + @api.model + def _get_credit_lines( + self, line_ids, partner_id, level_id, currency_id, company_id + ): + # Handle case when no policy_level is provided. + if level_id: + return super()._get_credit_lines( + line_ids, partner_id, level_id, currency_id, company_id + ) + cr_line_obj = self.env["credit.control.line"] + domain = [ + ("id", "in", line_ids), + ("partner_id", "=", partner_id), + ("currency_id", "=", currency_id), + ("company_id", "=", company_id), + ] + return cr_line_obj.search(domain, order="level DESC") + + @api.model + def _sql_credit_lines_groups(self): + # Remove level when aggregation is enabled on policy + # returns NULL instead of an ID for the level + return ( + "SELECT DISTINCT" + " partner_id," + " CASE" + " WHEN policy.aggregate_levels" + " THEN NULL" + " ELSE policy_level.id" + " END AS policy_level_id," + " line.currency_id," + " line.company_id" + " FROM credit_control_line AS line" + " JOIN credit_control_policy_level as policy_level" + " ON (line.policy_level_id = policy_level.id)" + " JOIN credit_control_policy as policy" + " ON (policy_level.policy_id = policy.id)" + " WHERE line.id in %s" + ) + + @api.model + def _prepare_communication_data(self, cr_lines): + # Redefine policy_level to ensure it's the highest + res = super()._prepare_communication_data(cr_lines) + highest_policy_lvl = cr_lines.mapped("policy_level_id").sorted("level")[0] + res["policy_level_id"] = highest_policy_lvl.id + return res diff --git a/account_credit_control_aggregate_level/models/credit_control_line.py b/account_credit_control_aggregate_level/models/credit_control_line.py new file mode 100644 index 000000000..64a16cf18 --- /dev/null +++ b/account_credit_control_aggregate_level/models/credit_control_line.py @@ -0,0 +1,143 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class CreditControlLine(models.Model): + _inherit = "credit.control.line" + + aggregation = fields.Selection( + selection=[ + ("no", "No aggregation"), + ("low", "Low level"), + ("highest", "Highest level"), + ], + help="`No aggregation` lines are not automatically aggregated " + "with other lines.\n" + "'Low level' lines are attached under higher level lines.\n" + "'Highest level' indicates that all 'Low level' lines for this " + "line's partner-policy combination will be attached to it.", + default="no", + readonly=True, + ) + + def _update_aggregation(self, exclude_ids=None): + self.ensure_one() + if not self.policy_id.aggregate_levels: + return + highest_related_line = self._get_highest_related_line(exclude_ids=exclude_ids) + highest_related_line.write({"aggregation": "highest"}) + self._get_related_lines( + exclude_ids=((exclude_ids or []) + highest_related_line.ids) + ).write({"aggregation": "low"}) + + def unlink(self): + for line in self: + line._update_aggregation(exclude_ids=line.ids) + return super().unlink() + + def write(self, values): + res = super().write(values) + for line in self: + if "state" in values and values.get("state") == "sent": + line.write({"aggregation": "no"}) + if "aggregation" not in values: + line._update_aggregation() + return res + + @api.model_create_multi + def create(self, vals_list): + lines = super().create(vals_list) + for line in lines: + if line.state == "sent": + line.write({"aggregation": "no"}) + else: + line._update_aggregation() + return lines + + def _get_highest_related_line(self, exclude_ids=None): + self.ensure_one() + return self._get_related_lines(exclude_ids=exclude_ids, limit=1) + + def _get_related_lines_domain(self, exclude_ids=None, level=None): + domain = [ + ("partner_id", "=", self.partner_id.id), + ("currency_id", "=", self.currency_id.id), + ("policy_id", "=", self.policy_id.id), + ("state", "in", ("draft", "to_be_sent")), + ] + if exclude_ids: + domain.append(("id", "not in", exclude_ids)) + if level: + domain.append(("level", "<=", level)) + return domain + + def _get_related_lines(self, exclude_ids=None, limit=None, level=None): + """ + Return lines from the same group if grouped + (ie with same partner, policy and currency). + + The most important line (ie the one to display to the user) + is the first one of the returned recordset. + """ + self.ensure_one() + if self.policy_id.aggregate_levels: + return self.search( + self._get_related_lines_domain(exclude_ids, level), + limit=limit, + order="level DESC, date_due ASC", + ) + else: + return self + + def _get_lower_related_lines(self): + """ + Return lines that will receive the same treatment + (ie lines of lower level from the same group if grouped). + """ + self.ensure_one() + if self.policy_id.aggregate_levels: + return self._get_related_lines(level=self.level) + else: + return self + + def button_schedule_activity(self): + ctx = self.env.context.copy() + ctx.update( + { + "default_res_id": self.ids[0], + "default_res_model": self._name, + } + ) + return { + "type": "ir.actions.act_window", + "name": _("Schedule activity"), + "res_model": "mail.activity", + "view_type": "form", + "view_mode": "form", + "res_id": self.activity_ids and self.activity_ids.ids[0] or False, + "views": [[False, "form"]], + "context": ctx, + "target": "new", + } + + def button_credit_control_line_form(self): + self.ensure_one() + action = self.env.ref("account_credit_control.credit_control_line_action") + form = self.env.ref("account_credit_control.credit_control_line_form") + action = action.read()[0] + action["views"] = [(form.id, "form")] + action["res_id"] = self.id + return action + + def act_show_aggregated_lines(self): + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": _("Credit Control Lines"), + "res_model": "credit.control.line", + "domain": [("id", "in", self._get_lower_related_lines().ids)], + "view_mode": "list,form", + "context": self.env.context, + } diff --git a/account_credit_control_aggregate_level/models/credit_control_policy.py b/account_credit_control_aggregate_level/models/credit_control_policy.py new file mode 100644 index 000000000..6db87e947 --- /dev/null +++ b/account_credit_control_aggregate_level/models/credit_control_policy.py @@ -0,0 +1,13 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import fields, models + + +class CreditControlPolicy(models.Model): + _inherit = "credit.control.policy" + aggregate_levels = fields.Boolean( + string="Aggregate levels", + help="When an action is performed on a credit control line generated " + "by this policy, lower level lines for the same partner and policy " + "will also be processed.", + ) diff --git a/account_credit_control_aggregate_level/pyproject.toml b/account_credit_control_aggregate_level/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/account_credit_control_aggregate_level/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/account_credit_control_aggregate_level/readme/CONFIGURE.md b/account_credit_control_aggregate_level/readme/CONFIGURE.md new file mode 100644 index 000000000..ebef05c23 --- /dev/null +++ b/account_credit_control_aggregate_level/readme/CONFIGURE.md @@ -0,0 +1,2 @@ +On credit policy enable ``Aggregate lower levels`` to aggregate lines from lower level and +same partner when a credit control line generated by this policy is processed. diff --git a/account_credit_control_aggregate_level/readme/CONTRIBUTORS.md b/account_credit_control_aggregate_level/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..abef232cb --- /dev/null +++ b/account_credit_control_aggregate_level/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [Acsone](https://www.acsone.eu): + - Quentin Groulard + - Yannick Payot diff --git a/account_credit_control_aggregate_level/readme/DESCRIPTION.md b/account_credit_control_aggregate_level/readme/DESCRIPTION.md new file mode 100644 index 000000000..9738e4eab --- /dev/null +++ b/account_credit_control_aggregate_level/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +Account Credit Control module is a part of Financial Tools used in +business to ensure that once sales are made they are realised as cash. +This module helps to identify outstanding debt beyond tolerance level +and setup followup method. diff --git a/account_credit_control_aggregate_level/readme/USAGE.md b/account_credit_control_aggregate_level/readme/USAGE.md new file mode 100644 index 000000000..dd260bed6 --- /dev/null +++ b/account_credit_control_aggregate_level/readme/USAGE.md @@ -0,0 +1,9 @@ +The option ``Aggregate lower levels`` on ``Credit Control Policy`` helps to +manage high credit control line generation rates (e.g. if you are selling +monthly subscriptions). +In such situation you may want to avoid spamming reminders to customers with +several credit control lines of different levels, and only take action for the +highest level. Thus, a communication is sent for a high level will also contain +the lower level lines. +Plus, you can use the filter ``Group Lines`` in the ``Credit Control Lines`` +menu to hide lines that will be auto-processed when a highest level line is. diff --git a/account_credit_control_aggregate_level/static/description/index.html b/account_credit_control_aggregate_level/static/description/index.html new file mode 100644 index 000000000..0455a17b5 --- /dev/null +++ b/account_credit_control_aggregate_level/static/description/index.html @@ -0,0 +1,450 @@ + + + + + +Account Credit Control + + + +
      +

      Account Credit Control

      + + +

      Beta License: AGPL-3 OCA/credit-control Translate me on Weblate Try me on Runboat

      +

      Account Credit Control module is a part of Financial Tools used in +business to ensure that once sales are made they are realised as cash. +This module helps to identify outstanding debt beyond tolerance level +and setup followup method.

      +

      Table of contents

      + +
      +

      Configuration

      +

      On credit policy enable Aggregate lower levels to aggregate lines +from lower level and same partner when a credit control line generated +by this policy is processed.

      +
      +
      +

      Usage

      +

      The option Aggregate lower levels on Credit Control Policy helps +to manage high credit control line generation rates (e.g. if you are +selling monthly subscriptions). In such situation you may want to avoid +spamming reminders to customers with several credit control lines of +different levels, and only take action for the highest level. Thus, a +communication is sent for a high level will also contain the lower level +lines. Plus, you can use the filter Group Lines in the +Credit Control Lines menu to hide lines that will be auto-processed +when a highest level line is.

      +
      +
      +

      Bug Tracker

      +

      Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

      +

      Do not contact contributors directly about support or help with technical issues.

      +
      +
      +

      Credits

      +
      +

      Authors

      +
        +
      • ACSONE SA/NV
      • +
      +
      +
      +

      Contributors

      +
        +
      • Acsone:
          +
        • Quentin Groulard
        • +
        • Yannick Payot
        • +
        +
      • +
      +
      +
      +

      Maintainers

      +

      This module is maintained by the OCA.

      + +Odoo Community Association + +

      OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

      +

      This module is part of the OCA/credit-control project on GitHub.

      +

      You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

      +
      +
      +
      + + diff --git a/account_credit_control_aggregate_level/tests/__init__.py b/account_credit_control_aggregate_level/tests/__init__.py new file mode 100644 index 000000000..c4486885f --- /dev/null +++ b/account_credit_control_aggregate_level/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import test_credit_control_line +from . import test_credit_control_run diff --git a/account_credit_control_aggregate_level/tests/test_credit_control_line.py b/account_credit_control_aggregate_level/tests/test_credit_control_line.py new file mode 100644 index 000000000..00a1adf70 --- /dev/null +++ b/account_credit_control_aggregate_level/tests/test_credit_control_line.py @@ -0,0 +1,124 @@ +# Copyright 2019 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestCreditControlLine(TransactionCase): + def test_aggregate_level(self): + account = self.env["account.account"].create( + { + "code": "400001", + "name": "Customers (test)", + "account_type": "asset_receivable", + "reconcile": True, + } + ) + + move = self.env["account.move"].create( + { + "name": "Test move", + "date": datetime.today(), + "state": "draft", + } + ) + + move_line = self.env["account.move.line"].create( + { + "account_id": account.id, + "move_id": move.id, + } + ) + + policy = self.env.ref("account_credit_control.credit_control_3_time") + policy.write( + { + "account_ids": [(6, 0, [account.id])], + "aggregate_levels": True, + } + ) + policy_level_1 = self.env.ref("account_credit_control.3_time_1") + policy_level_1.delay_days = 1 + + policy_level_2 = self.env.ref("account_credit_control.3_time_2") + policy_level_2.delay_days = 1 + + policy_level_3 = self.env.ref("account_credit_control.3_time_3") + policy_level_3.delay_days = 1 + + partner = self.env["res.partner"].create( + { + "name": "Partner", + "property_account_receivable_id": account.id, + } + ) + partner.credit_policy_id = policy.id + + ccl_1 = self.env["credit.control.line"].create( + { + "date": datetime.today(), + "date_due": datetime.today(), + "state": "draft", + "partner_id": partner.id, + "account_id": account.id, + "policy_level_id": policy_level_1.id, + "channel": "email", + "amount_due": 100, + "balance_due": 100, + "move_line_id": move_line.id, + } + ) + + self.assertEqual(ccl_1.aggregation, "highest") + + ccl_2 = self.env["credit.control.line"].create( + { + "date": datetime.today(), + "date_due": datetime.today(), + "state": "draft", + "partner_id": partner.id, + "account_id": account.id, + "policy_level_id": policy_level_2.id, + "channel": "email", + "amount_due": 100, + "balance_due": 100, + "move_line_id": move_line.id, + } + ) + + self.assertEqual(ccl_1.aggregation, "low") + self.assertEqual(ccl_2.aggregation, "highest") + self.assertTrue(ccl_1 in ccl_2._get_lower_related_lines()) + ccl_1.write({"policy_level_id": policy_level_3.id}) + + self.assertEqual(ccl_1.aggregation, "highest") + self.assertEqual(ccl_2.aggregation, "low") + self.assertTrue(ccl_2 in ccl_1._get_lower_related_lines()) + + ccl_1.unlink() + + self.assertEqual(ccl_2.aggregation, "highest") + + policy.write({"aggregate_levels": False}) + + ccl_3 = self.env["credit.control.line"].create( + { + "date": datetime.today(), + "date_due": datetime.today(), + "state": "sent", + "partner_id": partner.id, + "account_id": account.id, + "policy_level_id": policy_level_2.id, + "channel": "email", + "amount_due": 100, + "balance_due": 100, + "move_line_id": move_line.id, + } + ) + + self.assertEqual(ccl_3.aggregation, "no") + self.assertEqual(ccl_3, ccl_3._get_related_lines()) diff --git a/account_credit_control_aggregate_level/tests/test_credit_control_run.py b/account_credit_control_aggregate_level/tests/test_credit_control_run.py new file mode 100644 index 000000000..389178240 --- /dev/null +++ b/account_credit_control_aggregate_level/tests/test_credit_control_run.py @@ -0,0 +1,255 @@ +# Copyright 2017 Okia SPRL (https://okia.be) +# Copyright 2020 Tecnativa - Manuel Calero +# Copyright 2020 Tecnativa - João Marques +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import re +from datetime import datetime + +from dateutil import relativedelta + +from odoo import fields +from odoo.exceptions import UserError +from odoo.tests import Form, RecordCapturer, tagged + +from odoo.addons.account.tests.common import AccountTestInvoicingCommon + + +@tagged("post_install", "-at_install") +class TestCreditControlRun(AccountTestInvoicingCommon): + """Redo checks of line generation with aggregat_levels activated""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env.user.groups_id |= cls.env.ref( + "account_credit_control.group_account_credit_control_manager" + ) + journal = cls.company_data["default_journal_sale"] + + account = cls.env["account.account"].create( + { + "code": "TEST430001", + "name": "Clients (test)", + "account_type": "asset_receivable", + "reconcile": True, + } + ) + tag_operation = cls.env.ref("account.account_tag_operating") + analytic_account = cls.env["account.account"].create( + { + "code": "TEST701001", + "name": "Ventes en Belgique (test)", + "account_type": "income", + "reconcile": True, + "tag_ids": [(6, 0, [tag_operation.id])], + } + ) + payment_term = cls.env.ref("account.account_payment_term_immediate") + product = cls.env["product.product"].create({"name": "Product test"}) + cls.policy = cls.env.ref("account_credit_control.credit_control_3_time") + cls.policy.write( + {"account_ids": [(6, 0, [account.id])], "aggregate_levels": True} + ) + + # There is a bug with Odoo ... + # The field "credit_policy_id" is considered as an "old field" and + # the field property_account_receivable_id like a "new field" + # The ORM will create the record with old field + # and update the record with new fields. + # However constrains are applied after the first creation. + partner = cls.env["res.partner"].create( + {"name": "Partner", "property_account_receivable_id": account.id} + ) + partner.credit_policy_id = cls.policy.id + date_invoice = datetime.today() - relativedelta.relativedelta(years=1) + + # Create an invoice + invoice_form = Form( + cls.env["account.move"].with_context( + default_move_type="out_invoice", check_move_validity=False + ) + ) + invoice_form.invoice_date = date_invoice + invoice_form.invoice_date_due = date_invoice + invoice_form.partner_id = partner + invoice_form.journal_id = journal + invoice_form.invoice_payment_term_id = payment_term + with invoice_form.invoice_line_ids.new() as invoice_line_form: + invoice_line_form.product_id = product + invoice_line_form.quantity = 1 + invoice_line_form.price_unit = 500 + invoice_line_form.account_id = analytic_account + invoice_line_form.tax_ids.clear() + cls.invoice = invoice_form.save() + cls.invoice.action_post() + + def test_check_run_date(self): + """ + Create a control run older than the last control run + """ + control_run = self.env["credit.control.run"].create( + {"date": fields.Date.today(), "policy_ids": [(6, 0, [self.policy.id])]} + ) + + with self.assertRaises(UserError): + today = datetime.today() + previous_date = today - relativedelta.relativedelta(days=15) + previous_date_str = fields.Date.to_string(previous_date) + control_run._check_run_date(previous_date_str) + + def test_generate_credit_lines(self): + """ + Test the method generate_credit_lines + """ + self.env = self.env( + context=dict( + self.env.context, + tracking_disable=False, + mail_create_nosubscribe=False, + ) + ) + control_run = self.env["credit.control.run"].create( + {"date": fields.Date.today(), "policy_ids": [(6, 0, [self.policy.id])]} + ) + extra_partner = self.env["res.partner"].create({"name": "Test extra partner"}) + self.invoice.partner_id.message_subscribe( + partner_ids=extra_partner.ids, + subtype_ids=self.env.ref( + "account_credit_control.mt_credit_control_new" + ).ids, + ) + + control_run.with_context(lang="en_US").generate_credit_lines() + + self.assertEqual(len(self.invoice.credit_control_line_ids), 1) + self.assertEqual(control_run.state, "done") + self.assertIn( + extra_partner, + self.invoice.credit_control_line_ids.message_follower_ids.mapped( + "partner_id" + ), + ) + + report_regex = ( + rf'

      Policy "{self.policy.name}" has generated ' + r"\d+ Credit Control Lines.

      " + ) + regex_result = re.match(report_regex, control_run.report) + self.assertIsNotNone(regex_result) + + def test_generate_credit_lines_with_max_level(self): + """ + Test the method generate_credit_lines with max level group. + For more than one invoice with date different due we need various credit control + runs. + """ + self.policy.apply_max_policy_level = True + + invoice1 = self.invoice.copy() + invoice1.invoice_date = "2024-12-01" + invoice1.invoice_date_due = "2024-12-01" + invoice1.action_post() + + invoice2 = self.invoice.copy() + invoice2.invoice_date = "2025-01-01" + invoice2.invoice_date_due = "2025-01-01" + invoice2.action_post() + + control_run = self.env["credit.control.run"].create( + {"date": "2024-12-30", "policy_ids": [(6, 0, [self.policy.id])]} + ) + control_run.with_context(lang="en_US").generate_credit_lines() + control_run.set_to_ready_lines() + # This module uses SQL queries to search records so we have to + # store previous control lines + self.env.cr.flush() + + control_run = self.env["credit.control.run"].create( + {"date": "2025-01-30", "policy_ids": [(6, 0, [self.policy.id])]} + ) + control_run.with_context(lang="en_US").generate_credit_lines() + self.assertEqual(len(control_run.line_ids.mapped("level")), 3) + # All control lines have the level 2 + self.assertEqual(set(control_run.line_ids.mapped("level")), {2}) + + def test_multi_credit_control_run(self): + """ + Generate several control run + """ + + six_months = datetime.today() - relativedelta.relativedelta(months=6) + six_months_str = fields.Date.to_string(six_months) + three_months = datetime.today() - relativedelta.relativedelta(months=2) + three_months_str = fields.Date.to_string(three_months) + + # First run + first_control_run = self.env["credit.control.run"].create( + {"date": six_months_str, "policy_ids": [(6, 0, [self.policy.id])]} + ) + first_control_run.with_context(lang="en_US").generate_credit_lines() + self.assertEqual(len(self.invoice.credit_control_line_ids), 1) + + # Second run + second_control_run = self.env["credit.control.run"].create( + {"date": three_months_str, "policy_ids": [(6, 0, [self.policy.id])]} + ) + second_control_run.with_context(lang="en_US").generate_credit_lines() + self.assertEqual(len(self.invoice.credit_control_line_ids), 2) + + # Last run + last_control_run = self.env["credit.control.run"].create( + {"date": fields.Date.today(), "policy_ids": [(6, 0, [self.policy.id])]} + ) + last_control_run.with_context(lang="en_US").generate_credit_lines() + self.assertEqual(len(self.invoice.credit_control_line_ids), 3) + + def test_sent_email_invoice_detail(self): + """ + Verify that the email is sent and includes the invoice details + """ + policy_level_expected = self.env.ref("account_credit_control.3_time_1") + # assign a email to ensure does not fallback to letter + self.invoice.partner_id.email = "test@test.com" + control_run = self.env["credit.control.run"].create( + {"date": fields.Date.today(), "policy_ids": [(6, 0, [self.policy.id])]} + ) + control_run.with_context(lang="en_US").generate_credit_lines() + self.assertTrue(len(self.invoice.credit_control_line_ids), 1) + control_lines = self.invoice.credit_control_line_ids + self.assertEqual(control_lines.policy_level_id, policy_level_expected) + # CASE 1: set the policy level to show invoice details = True + control_lines.policy_level_id.mail_show_invoice_detail = True + marker = self.env["credit.control.marker"].create( + {"name": "to_be_sent", "line_ids": [(6, 0, control_lines.ids)]} + ) + marker.mark_lines() + emailer_obj = self.env["credit.control.emailer"].with_context( + domain_notifications_email="test@example.com" + ) + wiz_emailer = emailer_obj.create({}) + wiz_emailer.line_ids = control_lines + with RecordCapturer(self.env["credit.control.communication"], []) as capture: + wiz_emailer.email_lines() + new_communication = capture.records + self.assertEqual(len(new_communication), 1) + self.assertEqual(len(new_communication.message_ids), 1) + # Verify that the email include the invoice details. + self.assertIn("Invoices summary", new_communication.message_ids.body) + self.assertIn(self.invoice.name, new_communication.message_ids.body) + # CASE 2: set the policy level to show invoice details = False + control_lines.policy_level_id.mail_show_invoice_detail = False + control_lines.state = "to_be_sent" + marker = self.env["credit.control.marker"].create( + {"name": "to_be_sent", "line_ids": [(6, 0, control_lines.ids)]} + ) + marker.mark_lines() + wiz_emailer = emailer_obj.create({}) + wiz_emailer.line_ids = control_lines + with RecordCapturer(self.env["credit.control.communication"], []) as capture: + wiz_emailer.email_lines() + new_communication = capture.records + self.assertEqual(len(new_communication), 1) + self.assertEqual(len(new_communication.message_ids), 1) + # Verify that the email does not include the invoice details. + self.assertNotIn("Invoices summary", new_communication.message_ids.body) + self.assertNotIn(self.invoice.name, new_communication.message_ids.body) diff --git a/account_credit_control_aggregate_level/views/credit_control_line.xml b/account_credit_control_aggregate_level/views/credit_control_line.xml new file mode 100644 index 000000000..71460f10c --- /dev/null +++ b/account_credit_control_aggregate_level/views/credit_control_line.xml @@ -0,0 +1,47 @@ + + + + Credit Control Lines + credit.control.line + + + + + + + + + + credit.control.line.list + credit.control.line + + + + +