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)