fix(selling): don't email cancelled proformas or copy their PDF

Address review findings:
- send_proforma_email rejects non-issued proformas, and the tab suppresses the
  action for cancelled rows, so a voided document can't be sent to a customer
- mark proforma_pdf as no_copy and disable amendment (a proforma is created
  only from a Sales Order), so a copied proforma can't carry the original's PDF
  and number
This commit is contained in:
Nabin Hait
2026-07-23 19:17:21 +05:30
parent e4fb5ed3c4
commit 0b71c943c1
4 changed files with 18 additions and 3 deletions

View File

@@ -301,7 +301,13 @@ Object.assign(erpnext.proforma, {
{
icon: "mail",
label: __("Send Email"),
action: (row, refresh) => this.send_email(frm, row.name, refresh),
action: (row, refresh) => {
if (row.status === "Cancelled") {
frappe.msgprint(__("A cancelled Proforma Invoice cannot be emailed."));
return;
}
this.send_email(frm, row.name, refresh);
},
},
],
},

View File

@@ -187,6 +187,7 @@
"fieldname": "proforma_pdf",
"fieldtype": "Attach",
"label": "Proforma PDF",
"no_copy": 1,
"read_only": 1
},
{
@@ -250,7 +251,6 @@
"owner": "Administrator",
"permissions": [
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,
@@ -264,7 +264,6 @@
"write": 1
},
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,

View File

@@ -212,6 +212,8 @@ def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
@frappe.whitelist()
def send_proforma_email(proforma_name: str, recipients: str) -> None:
proforma = frappe.get_doc("Proforma Invoice", proforma_name)
if proforma.docstatus != 1:
frappe.throw(_("Only an issued Proforma Invoice can be emailed."))
if not proforma.proforma_pdf:
frappe.throw(_("This Proforma Invoice has no PDF to send."))

View File

@@ -9,6 +9,7 @@ from frappe.utils import flt
from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
get_sales_order_items,
make_proforma_invoice,
send_proforma_email,
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
@@ -144,6 +145,13 @@ class TestProformaInvoice(ERPNextTestSuite):
[(sales_order.items[0].name, 4)],
)
def test_cannot_email_cancelled_proforma(self):
sales_order = make_sales_order(qty=10)
proforma = self.create_proforma(sales_order, [(sales_order.items[0].name, 4)])
proforma.cancel()
self.assertRaises(frappe.ValidationError, send_proforma_email, proforma.name, "customer@example.com")
def test_requires_submitted_sales_order(self):
"""The server rejects a proforma against a draft Sales Order (the button is JS-gated only)."""
sales_order = make_sales_order(qty=10, do_not_submit=True)