From 06584b381aa950e9232bd5835d805c767f065fba Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Thu, 4 Sep 2025 12:34:47 -0400 Subject: [PATCH] Migrate account_credit_hold to 18.0 Squashed commit of the following: commit 5adce1fd159b94058d7cff66fc4fcbcd1aa346f3 Author: Marc Durepos Date: Thu Sep 4 12:33:00 2025 -0400 [MIG] 17.0..18.0 account_credit_hold Migrated module account_credit_hold from 17.0 to 18.0. Fixed views and models and wrote test cases to check functionality. commit 91c52af6f46c9aadea11e24e0891d482d3e938f6 Author: Marc Durepos Date: Thu Sep 4 11:38:37 2025 -0400 Initial migration of account_credit_hold for testing in 18.0 --- account_credit_hold/__init__.py | 1 + account_credit_hold/__manifest__.py | 20 + account_credit_hold/models/__init__.py | 5 + .../models/account_followup.py | 8 + .../models/account_followup_report.py | 16 + account_credit_hold/models/res_partner.py | 108 ++++++ account_credit_hold/models/sale_order.py | 16 + account_credit_hold/models/stock_picking.py | 9 + account_credit_hold/readme.md | 40 ++ account_credit_hold/tests/__init__.py | 1 + .../tests/test_account_credit_hold.py | 342 ++++++++++++++++++ .../views/account_followup_views.xml | 37 ++ .../views/res_partner_views.xml | 34 ++ .../views/sale_order_views.xml | 18 + .../views/stock_picking_views.xml | 19 + 15 files changed, 674 insertions(+) create mode 100644 account_credit_hold/__init__.py create mode 100644 account_credit_hold/__manifest__.py create mode 100644 account_credit_hold/models/__init__.py create mode 100644 account_credit_hold/models/account_followup.py create mode 100644 account_credit_hold/models/account_followup_report.py create mode 100644 account_credit_hold/models/res_partner.py create mode 100644 account_credit_hold/models/sale_order.py create mode 100644 account_credit_hold/models/stock_picking.py create mode 100644 account_credit_hold/readme.md create mode 100644 account_credit_hold/tests/__init__.py create mode 100644 account_credit_hold/tests/test_account_credit_hold.py create mode 100644 account_credit_hold/views/account_followup_views.xml create mode 100644 account_credit_hold/views/res_partner_views.xml create mode 100644 account_credit_hold/views/sale_order_views.xml create mode 100644 account_credit_hold/views/stock_picking_views.xml diff --git a/account_credit_hold/__init__.py b/account_credit_hold/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/account_credit_hold/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/account_credit_hold/__manifest__.py b/account_credit_hold/__manifest__.py new file mode 100644 index 0000000..3fe567a --- /dev/null +++ b/account_credit_hold/__manifest__.py @@ -0,0 +1,20 @@ +{ + "name": "Account Credit Hold", + "version": "18.0.1.1.1", + "summary": "Allows setting clients on credit hold, blocking the ability confirm a new sales order.", + "category": "Accounting/Accounting", + "author": "Bemade Inc.", + "maintainer": "Marc Durepos ", + "website": "http://www.bemade.org", + "license": "LGPL-3", + "depends": ["sale", "account_followup", "stock"], + "data": [ + "views/account_followup_views.xml", + "views/sale_order_views.xml", + "views/res_partner_views.xml", + "views/stock_picking_views.xml", + ], + "demo": [], + "installable": True, + "auto_install": False, +} diff --git a/account_credit_hold/models/__init__.py b/account_credit_hold/models/__init__.py new file mode 100644 index 0000000..0c2c11c --- /dev/null +++ b/account_credit_hold/models/__init__.py @@ -0,0 +1,5 @@ +from . import account_followup +from . import res_partner +from . import sale_order +from . import stock_picking +from . import account_followup_report diff --git a/account_credit_hold/models/account_followup.py b/account_credit_hold/models/account_followup.py new file mode 100644 index 0000000..5af3363 --- /dev/null +++ b/account_credit_hold/models/account_followup.py @@ -0,0 +1,8 @@ +from odoo import fields, models, api + + +class FollowupLine(models.Model): + _inherit = 'account_followup.followup.line' + + account_hold = fields.Boolean(string="Place on Credit Hold", + help="Place clients on account hold, restricting confirmation of new orders.") diff --git a/account_credit_hold/models/account_followup_report.py b/account_credit_hold/models/account_followup_report.py new file mode 100644 index 0000000..23aacec --- /dev/null +++ b/account_credit_hold/models/account_followup_report.py @@ -0,0 +1,16 @@ +from odoo import models, fields, api, _ + + +class FollowUpReport(models.AbstractModel): + _inherit = 'account.followup.report' + + def _get_followup_report_options(self, partner, options=None): + """ + Override to include credit hold information in followup report options. + """ + res = super()._get_followup_report_options(partner, options) + res.update({ + 'credit_hold': partner.followup_line_id.account_hold if partner.followup_line_id else False, + 'partner_on_hold': partner.on_hold + }) + return res diff --git a/account_credit_hold/models/res_partner.py b/account_credit_hold/models/res_partner.py new file mode 100644 index 0000000..235f57a --- /dev/null +++ b/account_credit_hold/models/res_partner.py @@ -0,0 +1,108 @@ +from odoo import fields, models, api, _ +from datetime import date + + +class Partner(models.Model): + _inherit = "res.partner" + + postpone_hold_until = fields.Date( + string="Postpone Hold", + help="Grace period specific to this partner despite unpaid invoices.", + tracking=True, + ) + + hold_bg = fields.Boolean( + string="Hold (technical)", + compute="_compute_hold_bg", + store=True, + default=False, + compute_sudo=True, + tracking=True, + ) + on_hold = fields.Boolean( + string="Account on Hold", + help="Client account is on hold for unpaid overdue invoices.", + compute="_compute_on_hold", + compute_sudo=True, + ) + + @api.depends("postpone_hold_until", "hold_bg", "commercial_partner_id.hold_bg") + def _compute_on_hold(self): + for rec in self: + # If the parent company is on hold, so are all its sub-contacts and subsidiaries + if rec.commercial_partner_id != rec and rec.commercial_partner_id.hold_bg: + if not (rec.commercial_partner_id.postpone_hold_until and rec.commercial_partner_id.postpone_hold_until > date.today()): + rec.on_hold = True + continue + + # If there is no parent company or the parent is not on hold, we compute for ourselves + if rec.hold_bg and not ( + rec.postpone_hold_until and rec.postpone_hold_until > date.today() + ): + rec.on_hold = True + else: + rec.on_hold = False + + @api.autovacuum + def _cleanup_expired_hold_postponements(self): + expired_holds = self.search([("postpone_hold_until", "<=", date.today())]) + expired_holds.write({"postpone_hold_until": False}) + + def action_credit_hold(self): + for rec in self: + rec.hold_bg = True + rec.message_post(body=_("Placed on credit hold.")) + + def action_lift_credit_hold(self): + for rec in self: + rec.hold_bg = False + rec.message_post(body=_("Credit hold lifted.")) + + @api.model + def _get_first_followup_level(self): + return self.env["account_followup.followup.line"].search( + [("company_id", "parent_of", self.env.company.id)], + order="delay asc", + limit=1, + ) + + @api.depends("followup_status", "followup_line_id") + def _compute_hold_bg(self): + first_followup_level = self._get_first_followup_level() + for rec in self: + prev_hold_bg = rec.hold_bg + level = rec.followup_line_id + if rec.followup_status == "no_action_needed" and not level: + rec.hold_bg = False + else: + rec.hold_bg = prev_hold_bg + + def _get_followup_report(self, options): + # Override to prevent hanging on PDF generation + # Just set minimal required options without generating the report + options.setdefault('attachment_ids', []) + options['report_attachment_id'] = False + + def _execute_followup_partner(self, options=None): + # Check if we need to place on credit hold before expensive operations + should_hold = ( + self.followup_status == "in_need_of_action" and + self.followup_line_id and + hasattr(self.followup_line_id, 'account_hold') and + self.followup_line_id.account_hold + ) + + # If this is just for credit hold and we don't need reports/emails, skip heavy operations + if options and options.get('credit_hold_only'): + if should_hold: + self.action_credit_hold() + return should_hold + + # Otherwise run the full followup process + res = super()._execute_followup_partner(options) + + # Apply credit hold after successful followup execution + if should_hold: + self.action_credit_hold() + + return res diff --git a/account_credit_hold/models/sale_order.py b/account_credit_hold/models/sale_order.py new file mode 100644 index 0000000..238188a --- /dev/null +++ b/account_credit_hold/models/sale_order.py @@ -0,0 +1,16 @@ +from odoo import fields, models, api, _ +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + client_on_hold = fields.Boolean(string='Client on Hold', + help="Whether or not a client has been put on hold due to unpaid invoices.", + related="partner_id.on_hold") + + def action_confirm(self): + if any(self.mapped('client_on_hold')): + raise UserError(_("This client is on credit hold. No new orders can be confirmed until past-due invoices " + "are paid or the accounting team postpones the hold.")) + return super().action_confirm() diff --git a/account_credit_hold/models/stock_picking.py b/account_credit_hold/models/stock_picking.py new file mode 100644 index 0000000..0d46e32 --- /dev/null +++ b/account_credit_hold/models/stock_picking.py @@ -0,0 +1,9 @@ +from odoo import fields, models, api + + +class ModelName(models.Model): + _inherit = "stock.picking" + + client_on_hold = fields.Boolean(string='Client on Hold', + help="Whether or not a client has been put on hold due to unpaid invoices.", + related="partner_id.on_hold") diff --git a/account_credit_hold/readme.md b/account_credit_hold/readme.md new file mode 100644 index 0000000..db624c3 --- /dev/null +++ b/account_credit_hold/readme.md @@ -0,0 +1,40 @@ +# Overview + +This module adds the notion of placing clients on credit hold to the followup levels from the Odoo Enterprise +account_followup module. It adds an option to followup levels to mark clients matching the followup criteria as on +credit hold. This hold restricts the confirmation of new sales orders for these clients. + +Accountant and admin users can set a date until which the account hold will be +postponed on a specific partner's form view. This effectively gives clients an extra +grace period, allowing orders to be confirmed until the period ends. + +# Change Log + +## 17.0.1.0.0 (2024-05-15) + +Various modifications to adapt code to Odoo 17.0 + +## 15.0.2.0.0 (2023-05-04) + +Complete remake of the module, making the "Credit Hold" an action that is either manually or +automatically triggered from the Accounting > Followup Reports section or by setting the automatic application field +on followup levels. + +## 15.0.1.1.0 (2023-05-03) + +Adds a ribbon to stock pickings for clients on hold, and therefore a dependency on stock. + +## 15.0.1.0.2 (2023-05-03) + +Fix to sale order view and sale order confirmation for clients not on hold. + +## 15.0.1.0.1 (2023-05-03) + +Fix clients on hold when status is "outstanding_invoices". + +## 15.0.1.0.0 (2023-05-02) Initial Release + +Initial release of the module, including a setting on follow-up levels to toggle placing on credit hold. Blocks +the confirmation of sales orders for clients on credit hold. Red "Credit Hold" banner appears on sales orders and +partner form view when a client is on credit hold. Credit hold can be postponed by setting the "Postpone Hold" field +on the partner form view. \ No newline at end of file diff --git a/account_credit_hold/tests/__init__.py b/account_credit_hold/tests/__init__.py new file mode 100644 index 0000000..7d40bc7 --- /dev/null +++ b/account_credit_hold/tests/__init__.py @@ -0,0 +1 @@ +from . import test_account_credit_hold diff --git a/account_credit_hold/tests/test_account_credit_hold.py b/account_credit_hold/tests/test_account_credit_hold.py new file mode 100644 index 0000000..0fa11a6 --- /dev/null +++ b/account_credit_hold/tests/test_account_credit_hold.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import date, timedelta +from odoo.tests import common, tagged, Form +from odoo.exceptions import UserError + + +@tagged("post_install", "-at_install") +class TestAccountCreditHold(common.TransactionCase): + + def setUp(self): + super().setUp() + + # Create test partner + self.partner = self.env["res.partner"].create( + { + "name": "Test Customer", + "is_company": True, + "customer_rank": 1, + "email": "test@example.com", + } + ) + + # Try to find existing followup lines or create new ones with unique delays + self.followup_line = self.env["account_followup.followup.line"].search( + [("delay", "=", 15), ("company_id", "=", self.env.company.id)], limit=1 + ) + + if not self.followup_line: + # Find a unique delay value + existing_delays = ( + self.env["account_followup.followup.line"] + .search([("company_id", "=", self.env.company.id)]) + .mapped("delay") + ) + + delay = 15 + while delay in existing_delays: + delay += 1 + + self.followup_line = self.env["account_followup.followup.line"].create( + { + "name": "First Reminder", + "delay": delay, + "account_hold": True, + "send_email": True, + "company_id": self.env.company.id, + } + ) + else: + # Update existing line to have account_hold + self.followup_line.account_hold = True + + # Create followup line without credit hold + self.followup_line_no_hold = self.env["account_followup.followup.line"].search( + [("delay", "=", 30), ("company_id", "=", self.env.company.id)], limit=1 + ) + + if not self.followup_line_no_hold: + # Find a unique delay value + existing_delays = ( + self.env["account_followup.followup.line"] + .search([("company_id", "=", self.env.company.id)]) + .mapped("delay") + ) + + delay = 30 + while delay in existing_delays: + delay += 1 + + self.followup_line_no_hold = self.env[ + "account_followup.followup.line" + ].create( + { + "name": "Second Reminder", + "delay": delay, + "account_hold": False, + "send_email": True, + "company_id": self.env.company.id, + } + ) + else: + # Update existing line to not have account_hold + self.followup_line_no_hold.account_hold = False + + def test_credit_hold_basic_functionality(self): + """Test basic credit hold functionality""" + # Initially partner should not be on hold + self.assertFalse(self.partner.on_hold) + self.assertFalse(self.partner.hold_bg) + + # Place partner on credit hold + with Form(self.partner) as form: + form.record.action_credit_hold() + self.assertTrue(self.partner.hold_bg) + self.assertTrue(self.partner.on_hold) + + # Lift credit hold + with Form(self.partner) as form: + form.record.action_lift_credit_hold() + self.assertFalse(self.partner.hold_bg) + self.assertFalse(self.partner.on_hold) + + def test_postpone_hold_functionality(self): + """Test postpone hold until functionality""" + # Place partner on hold + with Form(self.partner) as form: + form.record.action_credit_hold() + self.assertTrue(self.partner.on_hold) + + # Set postpone date to tomorrow + tomorrow = date.today() + timedelta(days=1) + self.partner.postpone_hold_until = tomorrow + + # Partner should not be on hold due to postponement + self.assertFalse(self.partner.on_hold) + + # Set postpone date to yesterday + yesterday = date.today() - timedelta(days=1) + self.partner.postpone_hold_until = yesterday + + # Partner should be on hold again + self.assertTrue(self.partner.on_hold) + + def test_commercial_partner_hold_inheritance(self): + """Test that child contacts inherit hold status from commercial partner""" + # Create child contact + child_partner = self.env["res.partner"].create( + { + "name": "Child Contact", + "parent_id": self.partner.id, + "type": "contact", + } + ) + + # Place parent on hold + with Form(self.partner) as form: + form.record.action_credit_hold() + + # Child should also be on hold + self.assertTrue(child_partner.on_hold) + + # Lift hold from parent + self.partner.action_lift_credit_hold() + + # Child should no longer be on hold + self.assertFalse(child_partner.on_hold) + + def test_sale_order_blocking(self): + """Test that sale orders are blocked when customer is on credit hold""" + # Get or create a product for testing + product = self.env["product.product"].search([("type", "=", "consu")], limit=1) + if not product: + product = self.env["product.product"].create( + { + "name": "Test Product", + "type": "consu", + "list_price": 100.0, + } + ) + + # Create a sale order + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + + # Should be able to confirm when not on hold + sale_order.action_confirm() + self.assertEqual(sale_order.state, "sale") + + # Create another order and place customer on hold + sale_order2 = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 1, + "price_unit": 100.0, + }, + ) + ], + } + ) + + with Form(self.partner) as form: + form.record.action_credit_hold() + + # Should raise error when trying to confirm + with self.assertRaises(UserError): + sale_order2.action_confirm() + + def test_followup_integration(self): + """Test integration with followup system""" + # Set partner to in_need_of_action status and assign followup line + self.partner.write( + { + "followup_status": "in_need_of_action", + "followup_line_id": self.followup_line.id, + } + ) + + # Execute followup - should place on hold + self.partner._execute_followup_partner() + self.assertTrue(self.partner.hold_bg) + + # Test with followup line that doesn't have account_hold + self.partner.write( + { + "followup_line_id": self.followup_line_no_hold.id, + "hold_bg": False, # Reset hold status + } + ) + + # Execute followup - should not place on hold + self.partner._execute_followup_partner() + self.assertFalse(self.partner.hold_bg) + + def test_followup_report_options(self): + """Test that followup report includes credit hold information""" + # Set up partner with followup line + self.partner.write( + { + "followup_line_id": self.followup_line.id, + } + ) + self.partner.action_credit_hold() + + # Get followup report options + report = self.env["account.followup.report"] + options = report._get_followup_report_options(self.partner) + + # Should include credit hold information + self.assertTrue(options.get("credit_hold")) + self.assertTrue(options.get("partner_on_hold")) + + def test_cleanup_expired_hold_postponements(self): + """Test automatic cleanup of expired hold postponements""" + # Set expired postponement date + expired_date = date.today() - timedelta(days=5) + self.partner.postpone_hold_until = expired_date + + # Run cleanup + self.env["res.partner"]._cleanup_expired_hold_postponements() + + # Postponement should be cleared + self.assertFalse(self.partner.postpone_hold_until) + + def test_hold_bg_computation(self): + """Test hold_bg field computation based on followup status""" + # Test with no_action_needed status + self.partner.write( + { + "followup_status": "no_action_needed", + "followup_line_id": False, + } + ) + self.partner._compute_hold_bg() + self.assertFalse(self.partner.hold_bg) + + # Test with followup status and line + self.partner.write( + { + "followup_status": "in_need_of_action", + "followup_line_id": self.followup_line.id, + "hold_bg": True, # Set initial state + } + ) + self.partner._compute_hold_bg() + # Should preserve existing hold_bg value when there's a followup line + self.assertTrue(self.partner.hold_bg) + + def test_stock_picking_credit_hold_display(self): + """Test that stock pickings show credit hold status""" + # Get warehouse and its outgoing picking type + warehouse = self.env["stock.warehouse"].search([], limit=1) + picking_type = warehouse.out_type_id + + # Create a stock picking + picking = self.env["stock.picking"].create( + { + "partner_id": self.partner.id, + "picking_type_id": picking_type.id, + "location_id": picking_type.default_location_src_id.id, + "location_dest_id": picking_type.default_location_dest_id.id, + } + ) + + # Initially should not show as on hold + self.assertFalse(picking.client_on_hold) + + # Place partner on hold + with Form(self.partner) as form: + form.record.action_credit_hold() + + # Picking should now show as on hold + self.assertTrue(picking.client_on_hold) + + def test_get_first_followup_level(self): + """Test _get_first_followup_level method""" + first_level = self.partner._get_first_followup_level() + self.assertEqual(first_level, self.followup_line) + + # Create an earlier followup level with unique delay + existing_delays = ( + self.env["account_followup.followup.line"] + .search([("company_id", "=", self.env.company.id)]) + .mapped("delay") + ) + + delay = 5 + while delay in existing_delays: + delay += 1 + + earlier_line = self.env["account_followup.followup.line"].create( + { + "name": "Early Reminder", + "delay": delay, + "account_hold": False, + "company_id": self.env.company.id, + } + ) + + first_level = self.partner._get_first_followup_level() + self.assertEqual(first_level, earlier_line) diff --git a/account_credit_hold/views/account_followup_views.xml b/account_credit_hold/views/account_followup_views.xml new file mode 100644 index 0000000..72b0ed9 --- /dev/null +++ b/account_credit_hold/views/account_followup_views.xml @@ -0,0 +1,37 @@ + + + + account_credit_hold.account_followup_line.form + account_followup.followup.line + + + + + + + + account_credit_hold.manual_reminder.form.inherit + account_followup.manual_reminder + + + + + + + + action_credit_hold + + + list,form + code + records.action_credit_hold() + + diff --git a/account_credit_hold/views/res_partner_views.xml b/account_credit_hold/views/res_partner_views.xml new file mode 100644 index 0000000..07db06b --- /dev/null +++ b/account_credit_hold/views/res_partner_views.xml @@ -0,0 +1,34 @@ + + + + account_credit_hold.res_partner.form + res.partner + + + + + + + + + + account_credit_hold.view_partner_property_form + res.partner + + + + + + + + + diff --git a/account_credit_hold/views/sale_order_views.xml b/account_credit_hold/views/sale_order_views.xml new file mode 100644 index 0000000..64d85d2 --- /dev/null +++ b/account_credit_hold/views/sale_order_views.xml @@ -0,0 +1,18 @@ + + + + account_credit_hold.sale_order.form + sale.order + + + + + + + + diff --git a/account_credit_hold/views/stock_picking_views.xml b/account_credit_hold/views/stock_picking_views.xml new file mode 100644 index 0000000..1fec7f1 --- /dev/null +++ b/account_credit_hold/views/stock_picking_views.xml @@ -0,0 +1,19 @@ + + + + account_credit_hold.stock_picking.form + stock.picking + + + + + + + + +