feat(selling): warn when total proforma exceeds the ordered qty/amount

Show a non-blocking notice below the item table when a line's total proforma
quantity (or amount) — this proforma plus already-issued ones — exceeds the
Sales Order line's ordered value. It updates live as the qty/amount or the
basis changes, and the user can still create the proforma.

Issued-proforma qty/amount are aggregated per line on demand (cancelled
proformas excluded); nothing is stored on the Sales Order.
This commit is contained in:
Nabin Hait
2026-07-17 12:59:41 +05:30
parent f711375885
commit 4177101ac0
3 changed files with 85 additions and 5 deletions

View File

@@ -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(
`<div class="text-danger small" style="margin-top: 8px;">${__(
"Total proforma {0} (including past proformas) exceeds the ordered {0} for: {1}",
[basis, frappe.utils.escape_html(exceeded.join(", "))]
)}</div>`
);
},
create(frm, dialog, values) {

View File

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

View File

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