From a6f94c91f479345ae116bd2f053b12cb050eb592 Mon Sep 17 00:00:00 2001 From: sayanthns Date: Fri, 26 Jun 2026 22:08:08 +0300 Subject: [PATCH] fix(dn-consolidation): clear custom_consolidated_si on SI on_trash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consolidated Sales Invoice (consolidate_dns_to_si) stamps each source Delivery Note with custom_consolidated_si = . That Link field makes Frappe's link-integrity check block delete/cancel of the SI. The clearing logic was wired only to Sales Invoice on_cancel (submitted → cancelled). A DRAFT consolidated SI is *deleted*, not cancelled, so nothing cleared the stamp → "Cannot delete or cancel ... is linked with Delivery Note ...", even though the standard Connections dashboard shows no link (consolidation deliberately omits SI Item.delivery_note for net-off). Fix: add Sales Invoice on_trash → clear_consolidated_si_links(). on_trash runs before check_if_doc_is_linked in frappe.delete_doc (v15), so the link check then passes. Extracted the clear loop into a shared helper and reused it from the existing on_cancel path (DRY). No-op for non-consolidated SIs. No schema change. Adds a draft-delete regression test. Co-Authored-By: Claude Opus 4.8 --- rmax_custom/api/delivery_note.py | 32 ++++++++++++++++++++++ rmax_custom/hooks.py | 3 ++ rmax_custom/inter_company_dn.py | 14 ++-------- rmax_custom/tests/test_dn_consolidation.py | 17 ++++++++++++ 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/rmax_custom/api/delivery_note.py b/rmax_custom/api/delivery_note.py index b23f580..03efb6a 100644 --- a/rmax_custom/api/delivery_note.py +++ b/rmax_custom/api/delivery_note.py @@ -672,6 +672,38 @@ def consolidate_dns_to_si(delivery_note_names): return si.name +def clear_consolidated_si_links(si_name): + """Clear the ``custom_consolidated_si`` back-reference on every Delivery + Note that points to this Sales Invoice. + + ``consolidate_dns_to_si`` stamps each source DN with + ``custom_consolidated_si = ``. That Link field makes Frappe's + link-integrity check (``check_if_doc_is_linked``) block cancel/delete of the + SI. Both the cancel path (submitted SI) and the trash path (draft SI delete) + must clear it first, or the operation is refused with + "Cannot delete or cancel ... is linked with Delivery Note ...". + """ + for dn_name in frappe.get_all( + "Delivery Note", + filters={"custom_consolidated_si": si_name}, + pluck="name", + ): + frappe.db.set_value( + "Delivery Note", dn_name, + "custom_consolidated_si", None, + update_modified=False, + ) + + +def sales_invoice_on_trash_clear_consolidated(doc, method=None): + """Sales Invoice ``on_trash``: free the netted-off DN stamps so a DRAFT + consolidated SI can be deleted. ``on_trash`` runs before + ``check_if_doc_is_linked`` in ``frappe.delete_doc`` (verified against v15), + so clearing here lets the link-integrity check pass. The submitted-SI case + is handled separately on ``on_cancel``. No-op for non-consolidated SIs.""" + clear_consolidated_si_links(doc.name) + + # --------------------------------------------------------------------------- # Multi-source return allocation APIs # --------------------------------------------------------------------------- diff --git a/rmax_custom/hooks.py b/rmax_custom/hooks.py index dfa0116..7f3411a 100644 --- a/rmax_custom/hooks.py +++ b/rmax_custom/hooks.py @@ -227,6 +227,9 @@ "rmax_custom.inter_company_dn.sales_invoice_on_cancel", "rmax_custom.api.delivery_note.sales_invoice_on_cancel_clear_dn_return", ], + "on_trash": [ + "rmax_custom.api.delivery_note.sales_invoice_on_trash_clear_consolidated", + ], }, "Quotation": { "before_insert": [ diff --git a/rmax_custom/inter_company_dn.py b/rmax_custom/inter_company_dn.py index a1d073d..23569ce 100644 --- a/rmax_custom/inter_company_dn.py +++ b/rmax_custom/inter_company_dn.py @@ -291,17 +291,9 @@ def sales_invoice_on_cancel(doc, method=None): # Clear the net-off consolidation stamp on every DN linked via # custom_consolidated_si. (Set by api.delivery_note.consolidate_dns_to_si.) - consolidated_dns = frappe.get_all( - "Delivery Note", - filters={"custom_consolidated_si": doc.name}, - pluck="name", - ) - for dn_name in consolidated_dns: - frappe.db.set_value( - "Delivery Note", dn_name, - "custom_consolidated_si", None, - update_modified=False, - ) + # Shared with the on_trash path. Local import avoids a circular import. + from rmax_custom.api.delivery_note import clear_consolidated_si_links + clear_consolidated_si_links(doc.name) def _source_dns_for_si(si_doc) -> set[str]: diff --git a/rmax_custom/tests/test_dn_consolidation.py b/rmax_custom/tests/test_dn_consolidation.py index 42ab2ea..311d17b 100644 --- a/rmax_custom/tests/test_dn_consolidation.py +++ b/rmax_custom/tests/test_dn_consolidation.py @@ -82,6 +82,23 @@ def test_si_cancel_clears_stamp(self): frappe.db.get_value("Delivery Note", dn.name, "custom_consolidated_si") ) + def test_draft_si_delete_clears_stamp_and_succeeds(self): + # Reproduces the bug: a DRAFT consolidated SI stamps the DN via + # custom_consolidated_si; deleting it must clear the stamp (on_trash) + # so the link-integrity check doesn't block the delete. + dn = _make_submitted_dn(self.customer, self.item, self.company, + self.warehouse, qty=5, rate=10) + si_name = consolidate_dns_to_si([dn.name]) + self.assertEqual( + frappe.db.get_value("Delivery Note", dn.name, "custom_consolidated_si"), + si_name, + ) + frappe.delete_doc("Sales Invoice", si_name) + self.assertFalse(frappe.db.exists("Sales Invoice", si_name)) + self.assertFalse( + frappe.db.get_value("Delivery Note", dn.name, "custom_consolidated_si") + ) + # --- fixtures helpers ---