From c543d15f3c70c1f8771085457cc52fafdf8cfcbd Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:14:58 +0200 Subject: [PATCH] feat: copy terms attachments to transactions (#53403) --- .../doctype/quotation/test_quotation.py | 91 +++++++++++++++++++ .../terms_and_conditions.json | 14 ++- .../terms_and_conditions.py | 1 + erpnext/utilities/transaction_base.py | 58 ++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 9c02879284c..2f25eb4ac75 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -183,6 +183,61 @@ class TestQuotation(ERPNextTestSuite): self.assertTrue(quotation.payment_schedule) + def test_terms_attachments_are_copied_to_quotation(self): + terms = make_terms_and_conditions(copy_attachments_to_transaction=True) + first_attachment = make_file_attachment( + "Terms and Conditions", + terms.name, + content="First terms attachment", + ) + + quotation = make_quotation(do_not_save=1) + quotation.tc_name = terms.name + quotation.insert() + + self.assertEqual(get_attachment_urls("Quotation", quotation.name), {first_attachment.file_url}) + + second_attachment = make_file_attachment( + "Terms and Conditions", + terms.name, + content="Second terms attachment", + ) + quotation.valid_till = add_days(getdate(quotation.valid_till), 1) + quotation.save() + + quotation_attachments = get_attachment_urls("Quotation", quotation.name) + self.assertEqual(quotation_attachments, {first_attachment.file_url}) + self.assertNotIn(second_attachment.file_url, quotation_attachments) + + new_terms = make_terms_and_conditions(copy_attachments_to_transaction=True) + new_terms_attachment = make_file_attachment( + "Terms and Conditions", + new_terms.name, + content="Attachment from updated terms", + ) + quotation.tc_name = new_terms.name + quotation.valid_till = add_days(getdate(quotation.valid_till), 1) + quotation.save() + + self.assertEqual( + get_attachment_urls("Quotation", quotation.name), + {first_attachment.file_url, new_terms_attachment.file_url}, + ) + + def test_terms_attachments_are_not_copied_when_disabled(self): + terms = make_terms_and_conditions(copy_attachments_to_transaction=False) + make_file_attachment( + "Terms and Conditions", + terms.name, + content="Terms attachment should stay on the template", + ) + + quotation = make_quotation(do_not_save=1) + quotation.tc_name = terms.name + quotation.insert() + + self.assertFalse(get_attachment_urls("Quotation", quotation.name)) + @ERPNextTestSuite.change_settings( "Accounts Settings", {"automatically_fetch_payment_terms": 1}, @@ -1148,6 +1203,42 @@ def get_quotation_dict(party_name=None, item_code=None): } +def make_terms_and_conditions(copy_attachments_to_transaction=False): + return frappe.get_doc( + { + "doctype": "Terms and Conditions", + "title": f"_Test Terms and Conditions {frappe.generate_hash(length=8)}", + "selling": 1, + "terms": "Test terms", + "copy_attachments_to_transaction": 1 if copy_attachments_to_transaction else 0, + } + ).insert() + + +def make_file_attachment(doctype, docname, content): + return frappe.get_doc( + { + "doctype": "File", + "file_name": f"terms-attachment-{frappe.generate_hash(length=8)}.txt", + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": content, + } + ).insert() + + +def get_attachment_urls(doctype, docname): + return { + file.file_url + for file in frappe.get_all( + "File", + filters={"attached_to_doctype": doctype, "attached_to_name": docname}, + fields=["file_url"], + ) + if file.file_url + } + + def make_quotation(**args): qo = frappe.new_doc("Quotation") args = frappe._dict(args) diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json index 6539aa45861..e33c638509b 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json @@ -11,6 +11,8 @@ "field_order": [ "title", "disabled", + "column_break_ofhb", + "copy_attachments_to_transaction", "applicable_modules_section", "selling", "buying", @@ -72,12 +74,22 @@ { "fieldname": "section_break_7", "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_ofhb", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "copy_attachments_to_transaction", + "fieldtype": "Check", + "label": "Copy Attachments to Transaction" } ], "icon": "icon-legal", "idx": 1, "links": [], - "modified": "2026-04-14 18:22:49.285298", + "modified": "2026-04-29 22:51:49.285298", "modified_by": "Administrator", "module": "Setup", "name": "Terms and Conditions", diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 7af36b8924d..32623605f51 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -21,6 +21,7 @@ class TermsandConditions(Document): from frappe.types import DF buying: DF.Check + copy_attachments_to_transaction: DF.Check disabled: DF.Check selling: DF.Check terms: DF.TextEditor | None diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index df863cb09ab..4e99e57c3af 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -18,6 +18,14 @@ class UOMMustBeIntegerError(frappe.ValidationError): class TransactionBase(StatusUpdater): + def on_change(self): + # `on_change` also fires for `db_set()`, so only run during an actual insert/save. + is_real_save = self.flags.in_insert or (self.doctype, self.name) in frappe.flags.currently_saving + if not is_real_save: + return + + self.copy_terms_and_conditions_attachments() + def validate_posting_time(self): # set Edit Posting Date and Time to 1 while data import and restore if (frappe.flags.in_import or self.flags.from_restore) and self.posting_date: @@ -36,6 +44,56 @@ class TransactionBase(StatusUpdater): def validate_uom_is_integer(self, uom_field, qty_fields, child_dt=None): validate_uom_is_integer(self, uom_field, qty_fields, child_dt) + def copy_terms_and_conditions_attachments(self): + if ( + not self.name + or not self.meta.has_field("tc_name") + or not self.tc_name + or not self.has_value_changed("tc_name") + ): + return + + copy_attachments_to_transaction = frappe.db.get_value( + "Terms and Conditions", self.tc_name, "copy_attachments_to_transaction" + ) + if not cint(copy_attachments_to_transaction): + return + + source_attachments = frappe.get_all( + "File", + filters={ + "attached_to_doctype": "Terms and Conditions", + "attached_to_name": self.tc_name, + }, + fields=["name", "file_url"], + ) + if not source_attachments: + return + + existing_file_urls = { + attachment.file_url + for attachment in frappe.get_all( + "File", + filters={ + "attached_to_doctype": self.doctype, + "attached_to_name": self.name, + }, + fields=["file_url"], + ) + if attachment.file_url + } + + for source_attachment in source_attachments: + if not source_attachment.file_url or source_attachment.file_url in existing_file_urls: + continue + + # Reuse the existing file metadata so the same on-disk blob is shared. + new_attachment = frappe.get_doc("File", source_attachment.name).create_attachment_copy( + attached_to_doctype=self.doctype, + attached_to_name=self.name, + ) + existing_file_urls.add(new_attachment.file_url) + def validate_with_previous_doc(self, ref): self.exclude_fields = ["conversion_factor", "uom"] if self.get("is_return") else []