diff --git a/account_credit_control/__manifest__.py b/account_credit_control/__manifest__.py index 3918fffa6..8e860489d 100644 --- a/account_credit_control/__manifest__.py +++ b/account_credit_control/__manifest__.py @@ -5,7 +5,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { "name": "Account Credit Control", - "version": "18.0.2.0.0", + "version": "18.0.3.0.0", "author": "Camptocamp," "Odoo Community Association (OCA)," "Okia," diff --git a/account_credit_control/data/data.xml b/account_credit_control/data/data.xml index 86e0452ce..76ddf5285 100644 --- a/account_credit_control/data/data.xml +++ b/account_credit_control/data/data.xml @@ -37,7 +37,7 @@ - email + Manual no follow Manual no follow @@ -52,7 +52,7 @@ - email + Our records indicate that we have not received the payment of the invoice mentioned below. @@ -81,7 +81,7 @@ Best regards - email + Our records indicate that we have not yet received the payment of the invoice mentioned below despite our first reminder. @@ -111,7 +111,7 @@ Best regards - letter + Our records indicate that we still have not received the payment of the invoice mentioned below despite our two reminders. If payment have already been sent, please disregard this notice. If not, please proceed with payment. @@ -153,7 +153,7 @@ Best regards - email + Our records indicate that we have not received the payment of the invoice mentioned below. @@ -182,7 +182,7 @@ Best regards - letter + Our records indicate that we still have not received the payment of the mentioned below invoice despite our reminder. diff --git a/account_credit_control/migrations/18.0.3.0.0/post-migration.py b/account_credit_control/migrations/18.0.3.0.0/post-migration.py new file mode 100644 index 000000000..e346cded3 --- /dev/null +++ b/account_credit_control/migrations/18.0.3.0.0/post-migration.py @@ -0,0 +1,53 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + cr = env.cr + if openupgrade.column_exists(cr, "credit_control_line", "channel_old"): + openupgrade.map_values( + cr, + "channel_old", + "channel_letter", + [("letter", "t")], + table="credit_control_line", + ) + openupgrade.map_values( + cr, + "channel_old", + "channel_email", + [("email", "t")], + table="credit_control_line", + ) + openupgrade.map_values( + cr, + "channel_old", + "channel_phone", + [("phone", "t")], + table="credit_control_line", + ) + if openupgrade.column_exists(cr, "credit_control_policy_level", "channel_old"): + openupgrade.map_values( + cr, + "channel_old", + "channel_letter", + [("letter", "t")], + table="credit_control_policy_level", + ) + openupgrade.map_values( + cr, + "channel_old", + "channel_email", + [("email", "t")], + table="credit_control_policy_level", + ) + openupgrade.map_values( + cr, + "channel_old", + "channel_phone", + [("phone", "t")], + table="credit_control_policy_level", + ) diff --git a/account_credit_control/migrations/18.0.3.0.0/pre-migration.py b/account_credit_control/migrations/18.0.3.0.0/pre-migration.py new file mode 100644 index 000000000..2ab4ac682 --- /dev/null +++ b/account_credit_control/migrations/18.0.3.0.0/pre-migration.py @@ -0,0 +1,28 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + # Preserve channel historic data from credit.control.line records + if openupgrade.column_exists(env.cr, "credit_control_line", "channel"): + openupgrade.copy_columns( + env.cr, + { + "credit_control_line": [ + ("channel", "channel_old", None), + ], + }, + ) + if openupgrade.column_exists(env.cr, "credit_control_policy_level", "channel"): + openupgrade.copy_columns( + env.cr, + { + "credit_control_policy_level": [ + ("channel", "channel_old", None), + ], + }, + ) diff --git a/account_credit_control/models/credit_control_communication.py b/account_credit_control/models/credit_control_communication.py index abe545a7d..5d66a2ffc 100644 --- a/account_credit_control/models/credit_control_communication.py +++ b/account_credit_control/models/credit_control_communication.py @@ -247,7 +247,7 @@ def _send_communications_by_email(self): ), ) - def _mark_credit_line_as_sent(self): + def _mark_credit_line_as_sent(self, channel): lines = self.mapped("credit_control_line_ids") - lines.write({"state": "sent"}) + lines._set_sent(channel) return lines diff --git a/account_credit_control/models/credit_control_line.py b/account_credit_control/models/credit_control_line.py index 6099afa57..8d41e6e74 100644 --- a/account_credit_control/models/credit_control_line.py +++ b/account_credit_control/models/credit_control_line.py @@ -6,8 +6,6 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError -from .credit_control_policy import CHANNEL_LIST - class CreditControlLine(models.Model): """A credit control line describes an amount due by a customer @@ -62,11 +60,15 @@ class CreditControlLine(models.Model): "Draft and ignored lines will be " "generated again on the next run.", ) - channel = fields.Selection( - selection=CHANNEL_LIST, - required=True, - readonly=False, - ) + + channel_email = fields.Boolean(string="By e-mail") + channel_letter = fields.Boolean(string="By post") + channel_phone = fields.Boolean(string="By phone") + + email_sent = fields.Boolean() + letter_sent = fields.Boolean() + phone_sent = fields.Boolean() + invoice_id = fields.Many2one(comodel_name="account.move", readonly=True) partner_id = fields.Many2one( comodel_name="res.partner", @@ -163,18 +165,21 @@ def _prepare_from_move_line( self, move_line, level, controlling_date, open_amount, default_lines_vals ): """Create credit control line""" - channel = level.channel + channel_email = level.channel_email + channel_letter = level.channel_letter partner = move_line.partner_id - # Fallback to letter - if channel == "email" and partner and not partner.email: - channel = "letter" + # Fallback to letter on missing email address + if channel_email and partner and not partner.email: + channel_email = False + channel_letter = True data = default_lines_vals.copy() data.update( { "date": controlling_date, "date_due": move_line.date_maturity, "state": "draft", - "channel": channel, + "channel_email": channel_email, + "channel_letter": channel_letter, "invoice_id": (move_line.move_id.id if move_line.move_id else False), "partner_id": partner.id, "amount_due": ( @@ -273,6 +278,50 @@ def create_or_update_from_mv_lines( return new_lines + def run_channel_letter(self): + if not self: + return + wiz = self.env["credit.control.printer"].create({"line_ids": self.ids}) + wiz.print_lines() + + def run_channel_email(self): + if not self: + return + comm_obj = self.env["credit.control.communication"] + comms = comm_obj._generate_comm_from_credit_lines(self) + comms._generate_emails() + + def run_channel_action(self): + lines = self.filtered(lambda rec: rec.state == "to_be_sent") + letter_lines = lines.filtered("channel_letter") + letter_lines.run_channel_letter() + + email_lines = lines.filtered("channel_email") + email_lines.run_channel_email() + + def _channel_list(self): + return ["email", "letter", "phone"] + + def _update_state_on_sent(self): + for rec in self: + sent = True + for chan in self._channel_list(): + to_be_sent = rec[f"channel_{chan}"] + sent_ok = rec[f"{chan}_sent"] + if to_be_sent and not sent_ok: + sent = False + break + if sent: + rec.state = "sent" + + def _set_sent(self, channel): + """Check all selected channels are done""" + channels = self._channel_list() + assert channel in channels + if channel: + self.write({f"{channel}_sent": True}) + self._update_state_on_sent() + def unlink(self): for line in self: if line.state != "draft": diff --git a/account_credit_control/models/credit_control_policy.py b/account_credit_control/models/credit_control_policy.py index d030113fb..9c17070e0 100644 --- a/account_credit_control/models/credit_control_policy.py +++ b/account_credit_control/models/credit_control_policy.py @@ -8,8 +8,6 @@ from odoo.exceptions import UserError, ValidationError from odoo.osv import expression -CHANNEL_LIST = [("letter", "Letter"), ("email", "Email"), ("phone", "Phone")] - class CreditControlPolicy(models.Model): """Define a policy of reminder""" @@ -320,7 +318,9 @@ class CreditControlPolicyLevel(models.Model): domain=[("model", "=", "credit.control.communication")], required=True, ) - channel = fields.Selection(selection=CHANNEL_LIST, required=True) + channel_email = fields.Boolean(string="By e-mail") + channel_letter = fields.Boolean(string="By post") + channel_phone = fields.Boolean(string="By phone") custom_text = fields.Text(string="Custom Message", required=True, translate=True) mail_show_invoice_detail = fields.Boolean(string="Show Invoice Details in mail") custom_mail_text = fields.Html( diff --git a/account_credit_control/models/credit_control_run.py b/account_credit_control/models/credit_control_run.py index 77816fb7b..95c99517d 100644 --- a/account_credit_control/models/credit_control_run.py +++ b/account_credit_control/models/credit_control_run.py @@ -155,7 +155,7 @@ def generate_credit_lines(self): """ try: self.env.cr.execute( - "SELECT id FROM credit_control_run" " LIMIT 1 FOR UPDATE NOWAIT" + "SELECT id FROM credit_control_run LIMIT 1 FOR UPDATE NOWAIT" ) except Exception as err: # In case of exception openerp will do a rollback @@ -200,16 +200,4 @@ def set_to_ready_lines(self): self.hide_change_state_button = True def run_channel_action(self): - self.ensure_one() - lines = self.line_ids.filtered(lambda x: x.state == "to_be_sent") - letter_lines = lines.filtered(lambda x: x.channel == "letter") - email_lines = lines.filtered(lambda x: x.channel == "email") - if email_lines: - comm_obj = self.env["credit.control.communication"] - comms = comm_obj._generate_comm_from_credit_lines(email_lines) - comms._generate_emails() - if letter_lines: - wiz = self.env["credit.control.printer"].create( - {"line_ids": letter_lines.ids} - ) - return wiz.print_lines + self.mapped("line_ids").run_channel_action() diff --git a/account_credit_control/models/mail_mail.py b/account_credit_control/models/mail_mail.py index c7902c976..bdce3d7bf 100644 --- a/account_credit_control/models/mail_mail.py +++ b/account_credit_control/models/mail_mail.py @@ -18,8 +18,10 @@ def _update_control_line_status(self): lines = self.env["credit.control.line"].search( [("communication_id", "=", msg.res_id), ("state", "=", "queued")] ) - new_state = "sent" if mail.state == "sent" else "email_error" - lines.write({"state": new_state}) + if mail.state == "sent": + lines._set_sent("email") + else: + lines.write({"state": "email_error"}) def _postprocess_sent_message( self, success_pids, failure_reason=False, failure_type=None diff --git a/account_credit_control/tests/test_credit_control_run.py b/account_credit_control/tests/test_credit_control_run.py index 758faca75..b7b5ed12a 100644 --- a/account_credit_control/tests/test_credit_control_run.py +++ b/account_credit_control/tests/test_credit_control_run.py @@ -333,6 +333,94 @@ def test_sent_email_no_invoice_detail(self): self.assertNotIn("Invoices summary", new_communication.message_ids.body) self.assertNotIn(self.invoice.name, new_communication.message_ids.body) + def test_sent_multi_channel_email_letter(self): + """ + Verify lines sent states changes + """ + policy_level = self.env.ref("account_credit_control.3_time_1") + policy_level.channel_letter = True + policy_level.channel_email = True + + # 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 + + marker = self.env["credit.control.marker"].create( + {"name": "to_be_sent", "line_ids": [(6, 0, control_lines.ids)]} + ) + marker.mark_lines() + + # Send the email + self.env.user.company_id.email = "test@example.com" + emailer_obj = self.env["credit.control.emailer"] + wiz_emailer = emailer_obj.create({}) + wiz_emailer.line_ids = control_lines + wiz_emailer.email_lines() + + self.assertEqual(control_lines[0].state, "queued") + self.assertEqual(control_lines[0].email_sent, True) + self.assertEqual(control_lines[0].letter_sent, False) + + # Print the PDF + printer_obj = self.env["credit.control.printer"] + wiz_printer = printer_obj.with_context( + active_model="credit.control.line", active_ids=control_lines.ids + ).create({}) + wiz_printer.print_lines() + + self.assertEqual(control_lines[0].state, "sent") + self.assertEqual(control_lines[0].email_sent, True) + self.assertEqual(control_lines[0].letter_sent, True) + + def test_sent_multi_channel_letter_email(self): + """ + Verify lines sent states changes + """ + policy_level = self.env.ref("account_credit_control.3_time_1") + policy_level.channel_letter = True + policy_level.channel_email = True + + # 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 + + marker = self.env["credit.control.marker"].create( + {"name": "to_be_sent", "line_ids": [(6, 0, control_lines.ids)]} + ) + marker.mark_lines() + + # Print the PDF + printer_obj = self.env["credit.control.printer"] + wiz_printer = printer_obj.with_context( + active_model="credit.control.line", active_ids=control_lines.ids + ).create({}) + wiz_printer.print_lines() + + self.assertEqual(control_lines[0].state, "to_be_sent") + self.assertEqual(control_lines[0].email_sent, False) + self.assertEqual(control_lines[0].letter_sent, True) + + # Send the email + self.env.user.company_id.email = "test@example.com" + emailer_obj = self.env["credit.control.emailer"] + wiz_emailer = emailer_obj.create({}) + wiz_emailer.line_ids = control_lines + wiz_emailer.email_lines() + + self.assertEqual(control_lines[0].state, "queue") + self.assertEqual(control_lines[0].email_sent, True) + self.assertEqual(control_lines[0].letter_sent, True) + def test_open_credit_lines(self): """ Test access rights when invoking method open_credit_lines diff --git a/account_credit_control/views/account_move.xml b/account_credit_control/views/account_move.xml index f2522659e..de029b6dc 100644 --- a/account_credit_control/views/account_move.xml +++ b/account_credit_control/views/account_move.xml @@ -34,7 +34,9 @@ - + + + diff --git a/account_credit_control/views/credit_control_communication.xml b/account_credit_control/views/credit_control_communication.xml index 8d6a88041..8e0478edb 100644 --- a/account_credit_control/views/credit_control_communication.xml +++ b/account_credit_control/views/credit_control_communication.xml @@ -39,7 +39,9 @@ - + + + diff --git a/account_credit_control/views/credit_control_line.xml b/account_credit_control/views/credit_control_line.xml index c92549319..185f973c6 100644 --- a/account_credit_control/views/credit_control_line.xml +++ b/account_credit_control/views/credit_control_line.xml @@ -21,11 +21,66 @@ + + - @@ -57,7 +112,9 @@ - + + + @@ -154,12 +211,6 @@ context="{'group_by': 'policy_level_id'}" string="Credit policy level" /> - - + + + diff --git a/account_credit_control/views/credit_control_policy.xml b/account_credit_control/views/credit_control_policy.xml index f4d7ae713..77172f8a4 100644 --- a/account_credit_control/views/credit_control_policy.xml +++ b/account_credit_control/views/credit_control_policy.xml @@ -33,7 +33,9 @@ - + + + @@ -44,10 +46,14 @@ - + + + + + @@ -63,7 +69,7 @@ /> - + + + + + @@ -155,7 +165,9 @@ - + + + diff --git a/account_credit_control/wizard/credit_control_emailer.py b/account_credit_control/wizard/credit_control_emailer.py index f0ff8dbbc..39d57a397 100644 --- a/account_credit_control/wizard/credit_control_emailer.py +++ b/account_credit_control/wizard/credit_control_emailer.py @@ -29,7 +29,7 @@ def _get_line_ids(self): comodel_name="credit.control.line", string="Credit Control Lines", default=lambda self: self._get_line_ids(), - domain=[("state", "=", "to_be_sent"), ("channel", "=", "email")], + domain=[("state", "=", "to_be_sent"), ("channel_email", "=", True)], ) @api.model @@ -39,7 +39,7 @@ def _filter_lines(self, lines): domain = [ ("state", "=", "to_be_sent"), ("id", "in", lines.ids), - ("channel", "=", "email"), + ("channel_email", "=", True), ] return line_obj.search(domain) diff --git a/account_credit_control/wizard/credit_control_printer.py b/account_credit_control/wizard/credit_control_printer.py index b8b0185cf..4f0968a47 100644 --- a/account_credit_control/wizard/credit_control_printer.py +++ b/account_credit_control/wizard/credit_control_printer.py @@ -51,7 +51,7 @@ def print_lines(self): comms = comm_obj._generate_comm_from_credit_lines(lines) if self.mark_as_sent: - comms._mark_credit_line_as_sent() + comms._mark_credit_line_as_sent("letter") report_name = "account_credit_control.report_credit_control_summary" report_obj = self.env["ir.actions.report"]._get_report_from_name(report_name) diff --git a/account_credit_control_attach_invoice/tests/test_ir_action_report.py b/account_credit_control_attach_invoice/tests/test_ir_action_report.py index b8ec89be4..3859f4369 100644 --- a/account_credit_control_attach_invoice/tests/test_ir_action_report.py +++ b/account_credit_control_attach_invoice/tests/test_ir_action_report.py @@ -39,7 +39,7 @@ def setUpClass(cls): "computation_mode": "net_days", "delay_days": 2, "email_template_id": cls.mail_template.id, - "channel": "email", + "channel_email": True, "custom_mail_text": "", "custom_text": "", } @@ -68,7 +68,7 @@ def setUpClass(cls): "partner_id": cls.partner.id, "move_line_id": cls.invoice.line_ids[0].id, "policy_level_id": cls.credit_control_policy_level.id, - "channel": "email", + "channel_email": True, "date": Datetime.now(), "date_due": Datetime.now(), "amount_due": 25,