diff --git a/account_credit_hold/models/account_followup_report.py b/account_credit_hold/models/account_followup_report.py
index a28efdc..23aacec 100644
--- a/account_credit_hold/models/account_followup_report.py
+++ b/account_credit_hold/models/account_followup_report.py
@@ -4,9 +4,13 @@ from odoo import models, fields, api, _
class FollowUpReport(models.AbstractModel):
_inherit = 'account.followup.report'
- def _get_line_info(self, followup_line):
- res = super()._get_line_info(followup_line)
+ 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': followup_line.account_hold
+ '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
index 7cdf9a1..235f57a 100644
--- a/account_credit_hold/models/res_partner.py
+++ b/account_credit_hold/models/res_partner.py
@@ -26,24 +26,21 @@ class Partner(models.Model):
compute_sudo=True,
)
- @api.depends("postpone_hold_until", "hold_bg")
+ @api.depends("postpone_hold_until", "hold_bg", "commercial_partner_id.hold_bg")
def _compute_on_hold(self):
- # manually re-compute hold_bg since followup_status doesn't get updated in Python but gets recalculated
- # by an SQL query every time
- self._compute_hold_bg()
for rec in self:
# If the parent company is on hold, so are all its sub-contacts and subsidiaries
- if rec.commercial_partner_id and rec.commercial_partner_id.on_hold:
- rec.on_hold = True
- return
+ 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:
- if rec.on_hold:
- rec.message_post(_("Credit hold lifted."))
rec.on_hold = False
@api.autovacuum
@@ -61,12 +58,13 @@ class Partner(models.Model):
rec.hold_bg = False
rec.message_post(body=_("Credit hold lifted."))
- def _execute_followup_partner(self, options=None):
- res = super()._execute_followup_partner(options)
- if self.followup_status == "in_need_of_action":
- if self.followup_line_id.account_hold:
- self.action_credit_hold()
- return res
+ @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):
@@ -78,3 +76,33 @@ class Partner(models.Model):
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
index 390e97e..238188a 100644
--- a/account_credit_hold/models/sale_order.py
+++ b/account_credit_hold/models/sale_order.py
@@ -9,9 +9,8 @@ class SaleOrder(models.Model):
help="Whether or not a client has been put on hold due to unpaid invoices.",
related="partner_id.on_hold")
- @api.depends('client_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."))
- super().action_confirm()
+ return super().action_confirm()
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
index 962cd79..72b0ed9 100644
--- a/account_credit_hold/views/account_followup_views.xml
+++ b/account_credit_hold/views/account_followup_views.xml
@@ -12,30 +12,18 @@
-
- customer.statements.form.view.inherit
- res.partner
+
+ account_credit_hold.manual_reminder.form.inherit
+ account_followup.manual_reminder
-
-
-
-
+
+
+ Credit Hold: This customer is currently on credit hold.
+