feat: copy terms attachments to transactions (backport #53403) (#54661)

Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
This commit is contained in:
mergify[bot]
2026-04-29 23:43:28 +02:00
committed by GitHub
parent b3001595ab
commit bd932da08b
4 changed files with 163 additions and 1 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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 []