Compare commits

...

10 Commits

Author SHA1 Message Date
Mihir Kandoi
ab34dc9025 test(selling): Proforma Invoice lines validated on direct insert 2026-09-24 22:06:50 +05:30
Mihir Kandoi
e19c3c6984 fix(selling): validate Proforma Invoice lines in the controller
The Sales Order check and the line rate and amount lived only in
make_proforma_invoice, so a proforma inserted through the REST API could
be submitted against a cancelled Sales Order, with lines from another
order or a stale amount. validate() now enforces them for every path.
2026-09-24 22:05:42 +05:30
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 181 additions and 62 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,59 @@ class ProformaInvoice(Document):
def validate(self) -> None:
validate_feature_enabled()
self.validate_amended_doc()
self.validate_sales_order()
self.set_item_values()
self.set_total_qty()
def validate_sales_order(self) -> None:
if frappe.db.get_value("Sales Order", self.sales_order, "docstatus") != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
def set_item_values(self) -> None:
"""Copy each line's item details from its Sales Order line, then set the rate and amount."""
so_items = {
row.name: row
for row in frappe.get_all(
"Sales Order Item",
filters={"parent": self.sales_order, "parenttype": "Sales Order"},
fields=["name", "item_code", "item_name", "description", "uom", "rate"],
)
}
for item in self.items:
so_item = so_items.get(item.so_detail)
if not so_item:
frappe.throw(
_("Row #{0}: The line does not belong to Sales Order {1}").format(
item.idx, frappe.bold(self.sales_order)
)
)
item.item_code = so_item.item_code
item.item_name = so_item.item_name
item.uom = so_item.uom
item.description = item.description or so_item.description
self.set_rate_and_amount(item, so_item.rate)
def set_rate_and_amount(self, item, sales_order_rate: float) -> None:
"""Quantity basis bills at the Sales Order rate; Amount basis derives the rate from the amount."""
if flt(item.qty) <= 0:
frappe.throw(_("Row #{0}: Qty must be a positive number").format(item.idx))
if self.based_on == "Amount":
if flt(item.amount) <= 0:
frappe.throw(_("Row #{0}: Amount must be a positive number").format(item.idx))
item.rate = flt(item.amount) / flt(item.qty)
else:
item.rate = sales_order_rate
item.amount = flt(item.qty) * flt(sales_order_rate)
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 +131,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 +168,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),
@@ -155,19 +208,13 @@ def make_proforma_invoice(
print_format: str | None = None,
letter_head: str | None = None,
) -> str:
"""The sole creation path for a Proforma Invoice (the doctype is `in_create`).
"""Create and submit a Proforma Invoice from the Sales Order dialog.
`based_on` decides what the user edited per line: "Quantity" (rate fixed, amount = qty x rate)
or "Amount" (both qty and amount entered, rate derived). `hide_item_qty` (Amount basis only)
hides the qty and rate on the printed proforma for a clean value-based document.
"""
validate_feature_enabled()
selected = frappe.parse_json(items)
sales_order_doc = frappe.get_doc("Sales Order", sales_order)
if sales_order_doc.docstatus != 1:
frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
so_items = {item.name: item for item in sales_order_doc.items}
proforma = frappe.new_doc("Proforma Invoice")
proforma.sales_order = sales_order
proforma.based_on = based_on
@@ -179,13 +226,16 @@ def make_proforma_invoice(
)
proforma.letter_head = letter_head
for row in selected:
so_item = so_items.get(row.get("so_detail"))
if not so_item:
continue
line = _proforma_line(so_item, based_on, row)
if line:
proforma.append("items", line)
for row in frappe.parse_json(items):
proforma.append(
"items",
{
"so_detail": row.get("so_detail"),
"qty": row.get("qty"),
"amount": row.get("amount"),
"description": row.get("description"),
},
)
if not proforma.items:
frappe.throw(_("Please enter a quantity or amount for at least one item."))
@@ -195,32 +245,6 @@ def make_proforma_invoice(
return proforma.name
def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
if based_on == "Amount":
# Amount basis: both qty and amount are user-entered; the rate is derived.
qty = flt(row.get("qty"))
amount = flt(row.get("amount"))
if amount <= 0 or qty <= 0:
return None
rate = amount / qty
else:
qty = flt(row.get("qty"))
if qty <= 0:
return None
rate = flt(so_item.rate)
amount = qty * rate
return {
"item_code": so_item.item_code,
"item_name": so_item.item_name,
"uom": so_item.uom,
"qty": qty,
"rate": rate,
"amount": amount,
"so_detail": so_item.name,
}
@frappe.whitelist()
def send_proforma_email(proforma_name: str, recipients: str) -> None:
proforma = frappe.get_doc("Proforma Invoice", proforma_name)

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,
@@ -24,6 +25,9 @@ class TestProformaInvoice(ERPNextTestSuite):
name = make_proforma_invoice(sales_order.name, json.dumps(items), **kwargs)
return frappe.get_doc("Proforma Invoice", name)
def make_draft_proforma(self, sales_order, **item):
return frappe.new_doc("Proforma Invoice", sales_order=sales_order.name, items=[item]).insert()
def test_partial_proforma_is_non_blocking(self):
"""A proforma must not touch delivery/billing or the source Sales Order."""
sales_order = make_sales_order(qty=10)
@@ -172,6 +176,74 @@ 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_line_from_another_sales_order_is_rejected(self):
sales_order = make_sales_order(qty=10)
other_item = make_sales_order(qty=10).items[0]
self.assertRaises(
frappe.ValidationError,
self.make_draft_proforma,
sales_order,
so_detail=other_item.name,
item_code=other_item.item_code,
qty=4,
)
def test_quantity_basis_bills_at_sales_order_rate(self):
sales_order = make_sales_order(qty=10)
so_item = sales_order.items[0]
proforma = self.make_draft_proforma(
sales_order, so_detail=so_item.name, item_code=so_item.item_code, qty=4, rate=1, amount=1
)
item = proforma.items[0]
self.assertEqual(item.item_code, so_item.item_code)
self.assertEqual(flt(item.rate), flt(so_item.rate))
self.assertEqual(flt(item.amount), 4 * flt(so_item.rate))
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",