Compare commits

...

8 Commits

Author SHA1 Message Date
Mihir Kandoi
defff46d8f test(selling): Update Items keeps a row with an issued proforma 2026-09-24 21:57:29 +05:30
Mihir Kandoi
7dd3598156 fix(selling): block deleting a proformed Sales Order row via Update Items
Deleting the row left the issued proforma pointing at a Sales Order
line that no longer exists.
2026-09-24 21:57:13 +05:30
Mihir Kandoi
6bf5fa51d5 test(selling): edited and default Proforma Invoice line description 2026-09-24 21:56:26 +05:30
Mihir Kandoi
d29ccfb569 feat(selling): editable item description on Proforma Invoice
The create dialog now shows each line's Sales Order description for
editing. The proforma line stores it, falling back to the Sales Order
description, and the print shows it under the item name.
2026-09-24 21:55:43 +05:30
Mihir Kandoi
c1808c9124 fix(selling): tidy the Proforma Invoice print
Print the customer address under the customer name, and drop the
Against Sales Order row and the not-a-demand-for-payment footer.
2026-09-24 21:52:26 +05:30
Mihir Kandoi
fa9d7a75e1 fix(selling): keep the Proforma tab on a cancelled Sales Order
Cancelling a Sales Order first needs its proformas cancelled, and the
tab then vanished, hiding the cancelled proformas that the list keeps
for audit. Show the list read-only instead.
2026-09-24 21:51:26 +05:30
Mihir Kandoi
02854a48ee test(selling): reject an amended Proforma Invoice 2026-09-24 21:50:45 +05:30
Mihir Kandoi
b03e48f453 fix(selling): block amending a cancelled Proforma Invoice
A proforma is only created from its Sales Order, but a cancelled one
showed Amend to Administrator, and the amended copy could be saved
outside that path.
2026-09-24 21:50:30 +05:30
8 changed files with 100 additions and 22 deletions

View File

@@ -458,6 +458,12 @@ def validate_child_on_delete(row, parent, ordered_item=None) -> None:
"Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
).format(row.idx, row.item_code)
)
if frappe.db.exists("Proforma Invoice Item", {"so_detail": row.name, "docstatus": 1}):
frappe.throw(
_("Row #{0}: Cannot delete item {1} which has an issued Proforma Invoice.").format(
row.idx, row.item_code
)
)
if parent.doctype == "Purchase Order" and flt(row.received_qty):
frappe.throw(

View File

@@ -4,19 +4,21 @@
frappe.ui.form.on("Sales Order", {
refresh(frm) {
erpnext.proforma.toggle_tab(frm, false);
if (frm.doc.docstatus !== 1) return;
if (frm.doc.docstatus === 0) return;
frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => {
if (!enabled) return;
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
if (frm.doc.docstatus === 1) {
// Defer so the button lands after the standard Create options, not before them.
setTimeout(() => {
frm.add_custom_button(
__("Proforma Invoice"),
() => erpnext.proforma.open_dialog(frm),
__("Create")
);
}, 0);
}
erpnext.proforma.render_list(frm);
});
},
@@ -117,6 +119,12 @@ Object.assign(erpnext.proforma, {
read_only: 1,
in_list_view: 1,
},
{
fieldname: "description",
fieldtype: "Text Editor",
label: __("Description"),
in_list_view: 1,
},
{
fieldname: "qty",
fieldtype: "Float",
@@ -205,11 +213,12 @@ Object.assign(erpnext.proforma, {
const by_amount = values.based_on === "Amount";
const items = (values.items || [])
.filter((row) => flt(by_amount ? row.amount : row.qty) > 0)
.map((row) =>
by_amount
? { so_detail: row.so_detail, qty: row.qty, amount: row.amount }
: { so_detail: row.so_detail, qty: row.qty }
);
.map((row) => ({
so_detail: row.so_detail,
description: row.description,
qty: row.qty,
amount: row.amount,
}));
if (!items.length) {
frappe.msgprint(__("Please enter a quantity or amount for at least one item."));
@@ -314,6 +323,7 @@ Object.assign(erpnext.proforma, {
],
});
list.refresh();
if (frm.doc.docstatus !== 1) return;
frappe.ui
.button({

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Proforma Invoice", {
// refresh(frm) {
// },
// });
frappe.ui.form.on("Proforma Invoice", {
refresh(frm) {
frm.page.btn_primary.toggle(frm.doc.docstatus !== 2);
},
});

View File

@@ -45,8 +45,17 @@ class ProformaInvoice(Document):
def validate(self) -> None:
validate_feature_enabled()
self.validate_amended_doc()
self.set_total_qty()
def validate_amended_doc(self) -> None:
if self.amended_from:
frappe.throw(
_("Cannot amend {0} {1}, please create a new one instead.").format(
self.doctype, frappe.bold(self.amended_from)
)
)
def before_submit(self) -> None:
self.status = "Issued"
@@ -80,6 +89,7 @@ class ProformaInvoice(Document):
for item in sales_order.items:
item.qty = lines[item.name].qty
item.rate = lines[item.name].rate
item.description = lines[item.name].description
item.discount_amount = 0
item.discount_percentage = 0
sales_order.run_method("calculate_taxes_and_totals")
@@ -116,6 +126,7 @@ def get_sales_order_items(sales_order: str) -> list[dict]:
{
"item_code": item.item_code,
"item_name": item.item_name,
"description": item.description,
"uom": item.uom,
"so_detail": item.name,
"qty": flt(item.qty),
@@ -213,6 +224,7 @@ def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
return {
"item_code": so_item.item_code,
"item_name": so_item.item_name,
"description": row.get("description") or so_item.description,
"uom": so_item.uom,
"qty": qty,
"rate": rate,

View File

@@ -6,6 +6,7 @@ import json
import frappe
from frappe.utils import flt
from erpnext.accounts.services.child_item_update import update_child_qty_rate
from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
get_sales_order_items,
make_proforma_invoice,
@@ -172,6 +173,48 @@ class TestProformaInvoice(ERPNextTestSuite):
("Proforma Invoice PRO-TEST-0001", "Please find attached the proforma invoice PRO-TEST-0001."),
)
def test_line_description_is_editable(self):
sales_order = make_sales_order(qty=10, do_not_submit=True)
sales_order.items[0].description = "Ordered description"
sales_order.submit()
so_detail = sales_order.items[0].name
edited = make_proforma_invoice(
sales_order.name, json.dumps([{"so_detail": so_detail, "qty": 4, "description": "Edited"}])
)
unedited = self.create_proforma(sales_order, [(so_detail, 4)])
self.assertEqual(get_sales_order_items(sales_order.name)[0]["description"], "Ordered description")
self.assertEqual(frappe.get_doc("Proforma Invoice", edited).items[0].description, "Edited")
self.assertEqual(unedited.items[0].description, "Ordered description")
def test_update_items_cannot_delete_a_proformed_row(self):
sales_order = make_sales_order(
item_list=[
{"item_code": "_Test Item", "qty": 5, "rate": 100},
{"item_code": "_Test Item 2", "qty": 2, "rate": 50},
]
)
proformed, other = sales_order.items
proforma = self.create_proforma(sales_order, [(proformed.name, 2)])
keep_other = json.dumps(
[{"item_code": other.item_code, "qty": other.qty, "rate": other.rate, "docname": other.name}]
)
self.assertRaises(
frappe.ValidationError, update_child_qty_rate, "Sales Order", keep_other, sales_order.name
)
proforma.cancel()
update_child_qty_rate("Sales Order", keep_other, sales_order.name)
sales_order.reload()
self.assertEqual([item.name for item in sales_order.items], [other.name])
def test_amended_proforma_is_rejected(self):
proforma = frappe.get_doc({"doctype": "Proforma Invoice", "amended_from": "PRO-TEST-0001"})
self.assertRaises(frappe.ValidationError, proforma.validate_amended_doc)
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)

View File

@@ -7,6 +7,7 @@
"field_order": [
"item_code",
"item_name",
"description",
"column_break_qty",
"qty",
"uom",
@@ -32,6 +33,11 @@
"label": "Item Name",
"read_only": 1
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description"
},
{
"fieldname": "column_break_qty",
"fieldtype": "Column Break"
@@ -79,7 +85,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-16 00:00:00.000000",
"modified": "2026-09-24 12:00:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Proforma Invoice Item",

View File

@@ -14,6 +14,7 @@ class ProformaInvoiceItem(Document):
from frappe.types import DF
amount: DF.Currency
description: DF.TextEditor | None
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data

View File

@@ -9,14 +9,14 @@
"docstatus": 0,
"doctype": "Print Format",
"font_size": 0,
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t\t.proforma-print .footer-note { margin-top: 30px; font-size: 11px; color: #6b7280; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Against Sales Order\") }}</td><td class=\"text-right\">{{ doc.name }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td><strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td><strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n\n\t<div class=\"footer-note\">\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t</div>\n</div>\n",
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t<strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}\n\t\t\t\t{% if doc.customer_address %}<br>{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td>\n\t\t\t\t\t<strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}\n\t\t\t\t\t{% if row.description and frappe.utils.strip_html(row.description).strip() != row.item_name %}<div class=\"muted\">{{ row.description }}</div>{% endif %}\n\t\t\t\t</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n</div>\n",
"idx": 0,
"line_breaks": 0,
"margin_bottom": 15.0,
"margin_left": 15.0,
"margin_right": 15.0,
"margin_top": 15.0,
"modified": "2026-07-16 00:00:00.000000",
"modified": "2026-09-24 12:30:00.000000",
"modified_by": "Administrator",
"module": "Selling",
"name": "Proforma Invoice",