diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js
index 3e4677e4f57..9d1c9c3b294 100644
--- a/erpnext/public/js/sales_order_proforma.js
+++ b/erpnext/public/js/sales_order_proforma.js
@@ -110,9 +110,11 @@ Object.assign(erpnext.proforma, {
in_list_view: 1,
onchange: function () {
// Keep the read-only Amount in sync while editing qty (Quantity basis).
- if (!this.doc) return;
- this.doc.amount = flt(this.doc.qty) * flt(this.doc.rate);
- this.grid_row?.refresh_field("amount");
+ if (this.doc) {
+ this.doc.amount = flt(this.doc.qty) * flt(this.doc.rate);
+ this.grid_row?.refresh_field("amount");
+ }
+ erpnext.proforma.update_warning(dialog);
},
},
{
@@ -121,18 +123,22 @@ Object.assign(erpnext.proforma, {
label: __("Amount"),
in_list_view: 1,
read_only: 1,
+ onchange: () => this.update_warning(dialog),
},
{ fieldname: "item_name", fieldtype: "Data", hidden: 1 },
{ fieldname: "rate", fieldtype: "Currency", hidden: 1 },
{ fieldname: "so_detail", fieldtype: "Data", hidden: 1 },
],
},
+ { fieldname: "warning_html", fieldtype: "HTML" },
],
primary_action_label: __("Create"),
primary_action: (values) => this.create(frm, dialog, values),
});
+ dialog._so_items = so_items;
dialog.show();
+ this.update_warning(dialog);
},
// Both Qty and Amount columns stay visible; only the one matching the chosen basis is editable.
@@ -141,6 +147,39 @@ Object.assign(erpnext.proforma, {
const grid = dialog.get_field("items").grid;
grid.toggle_enable("qty", !by_amount);
grid.toggle_enable("amount", by_amount);
+ this.update_warning(dialog);
+ },
+
+ // Non-blocking notice below the table: flag lines whose total proforma qty/amount (this
+ // proforma plus already-issued ones) exceeds the ordered qty/amount for the chosen basis.
+ update_warning(dialog) {
+ const by_amount = dialog.get_value("based_on") === "Amount";
+ const field = by_amount ? "amount" : "qty";
+ const proformed_field = by_amount ? "proformed_amount" : "proformed_qty";
+ const so_item = {};
+ (dialog._so_items || []).forEach((row) => (so_item[row.so_detail] = row));
+
+ const exceeded = [];
+ (dialog.get_value("items") || []).forEach((row) => {
+ const item = so_item[row.so_detail];
+ if (!item) return;
+ const ordered = flt(by_amount ? item.amount : item.qty);
+ const total = flt(item[proformed_field]) + flt(row[field]);
+ if (total > ordered + 0.0001) exceeded.push(item.item_code);
+ });
+
+ const $wrapper = dialog.get_field("warning_html").$wrapper;
+ if (!exceeded.length) {
+ $wrapper.empty();
+ return;
+ }
+ const basis = by_amount ? __("amount") : __("quantity");
+ $wrapper.html(
+ `
${__(
+ "Total proforma {0} (including past proformas) exceeds the ordered {0} for: {1}",
+ [basis, frappe.utils.escape_html(exceeded.join(", "))]
+ )}
`
+ );
},
create(frm, dialog, values) {
diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py
index 9569db2d5c1..77a5430b826 100644
--- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py
+++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py
@@ -4,6 +4,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.query_builder.functions import Sum
from frappe.utils import flt, now
from frappe.utils.file_manager import save_file
@@ -96,8 +97,9 @@ class ProformaInvoice(Document):
@frappe.whitelist()
def get_sales_order_items(sales_order: str) -> list[dict]:
- """Sales Order lines used to pre-fill the create-proforma dialog."""
+ """Sales Order lines (with already-proformed totals) to drive the create-proforma dialog."""
sales_order_doc = frappe.get_doc("Sales Order", sales_order)
+ proformed = get_proformed_totals(sales_order)
return [
{
"item_code": item.item_code,
@@ -107,11 +109,30 @@ def get_sales_order_items(sales_order: str) -> list[dict]:
"qty": flt(item.qty),
"rate": flt(item.rate),
"amount": flt(item.amount),
+ "proformed_qty": flt(proformed.get(item.name, {}).get("qty")),
+ "proformed_amount": flt(proformed.get(item.name, {}).get("amount")),
}
for item in sales_order_doc.items
]
+def get_proformed_totals(sales_order: str) -> dict[str, dict]:
+ """Sum of issued (docstatus = 1) proforma qty and amount per Sales Order Item row."""
+ proformas = frappe.get_all(
+ "Proforma Invoice", filters={"sales_order": sales_order, "docstatus": 1}, pluck="name"
+ )
+ if not proformas:
+ return {}
+ item = frappe.qb.DocType("Proforma Invoice Item")
+ rows = (
+ frappe.qb.from_(item)
+ .select(item.so_detail, Sum(item.qty).as_("qty"), Sum(item.amount).as_("amount"))
+ .where(item.parent.isin(proformas))
+ .groupby(item.so_detail)
+ ).run(as_dict=True)
+ return {row.so_detail: {"qty": flt(row.qty), "amount": flt(row.amount)} for row in rows}
+
+
@frappe.whitelist()
def make_proforma_invoice(
sales_order: str,
diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py
index c48de3f041c..bcca75b897c 100644
--- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py
+++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py
@@ -6,7 +6,10 @@ import json
import frappe
from frappe.utils import flt
-from erpnext.selling.doctype.proforma_invoice.proforma_invoice import make_proforma_invoice
+from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
+ get_sales_order_items,
+ make_proforma_invoice,
+)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
@@ -92,6 +95,23 @@ class TestProformaInvoice(ERPNextTestSuite):
self.assertEqual(proforma.status, "Cancelled")
self.assertEqual(proforma.proforma_pdf, pdf)
+ def test_proformed_totals_exclude_cancelled(self):
+ """Cumulative issued proforma qty/amount per line, used by the dialog warning."""
+ sales_order = make_sales_order(qty=10) # rate 100
+ so_detail = sales_order.items[0].name
+
+ first = self.create_proforma(sales_order, [(so_detail, 4)])
+ self.create_proforma(sales_order, [(so_detail, 3)])
+
+ data = get_sales_order_items(sales_order.name)[0]
+ self.assertEqual(flt(data["proformed_qty"]), 7)
+ self.assertEqual(flt(data["proformed_amount"]), 700)
+
+ first.cancel()
+ data = get_sales_order_items(sales_order.name)[0]
+ self.assertEqual(flt(data["proformed_qty"]), 3)
+ self.assertEqual(flt(data["proformed_amount"]), 300)
+
def test_feature_toggle_is_enforced(self):
sales_order = make_sales_order(qty=10)
frappe.db.set_single_value("Selling Settings", "enable_proforma_invoice", 0)