mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-01 23:53:21 +00:00
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:
@@ -110,9 +110,11 @@ Object.assign(erpnext.proforma, {
|
|||||||
in_list_view: 1,
|
in_list_view: 1,
|
||||||
onchange: function () {
|
onchange: function () {
|
||||||
// Keep the read-only Amount in sync while editing qty (Quantity basis).
|
// Keep the read-only Amount in sync while editing qty (Quantity basis).
|
||||||
if (!this.doc) return;
|
if (this.doc) {
|
||||||
this.doc.amount = flt(this.doc.qty) * flt(this.doc.rate);
|
this.doc.amount = flt(this.doc.qty) * flt(this.doc.rate);
|
||||||
this.grid_row?.refresh_field("amount");
|
this.grid_row?.refresh_field("amount");
|
||||||
|
}
|
||||||
|
erpnext.proforma.update_warning(dialog);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -121,18 +123,22 @@ Object.assign(erpnext.proforma, {
|
|||||||
label: __("Amount"),
|
label: __("Amount"),
|
||||||
in_list_view: 1,
|
in_list_view: 1,
|
||||||
read_only: 1,
|
read_only: 1,
|
||||||
|
onchange: () => this.update_warning(dialog),
|
||||||
},
|
},
|
||||||
{ fieldname: "item_name", fieldtype: "Data", hidden: 1 },
|
{ fieldname: "item_name", fieldtype: "Data", hidden: 1 },
|
||||||
{ fieldname: "rate", fieldtype: "Currency", hidden: 1 },
|
{ fieldname: "rate", fieldtype: "Currency", hidden: 1 },
|
||||||
{ fieldname: "so_detail", fieldtype: "Data", hidden: 1 },
|
{ fieldname: "so_detail", fieldtype: "Data", hidden: 1 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ fieldname: "warning_html", fieldtype: "HTML" },
|
||||||
],
|
],
|
||||||
primary_action_label: __("Create"),
|
primary_action_label: __("Create"),
|
||||||
primary_action: (values) => this.create(frm, dialog, values),
|
primary_action: (values) => this.create(frm, dialog, values),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dialog._so_items = so_items;
|
||||||
dialog.show();
|
dialog.show();
|
||||||
|
this.update_warning(dialog);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Both Qty and Amount columns stay visible; only the one matching the chosen basis is editable.
|
// 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;
|
const grid = dialog.get_field("items").grid;
|
||||||
grid.toggle_enable("qty", !by_amount);
|
grid.toggle_enable("qty", !by_amount);
|
||||||
grid.toggle_enable("amount", 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) {
|
create(frm, dialog, values) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe.query_builder.functions import Sum
|
||||||
from frappe.utils import flt, now
|
from frappe.utils import flt, now
|
||||||
from frappe.utils.file_manager import save_file
|
from frappe.utils.file_manager import save_file
|
||||||
|
|
||||||
@@ -96,8 +97,9 @@ class ProformaInvoice(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_sales_order_items(sales_order: str) -> list[dict]:
|
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)
|
sales_order_doc = frappe.get_doc("Sales Order", sales_order)
|
||||||
|
proformed = get_proformed_totals(sales_order)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"item_code": item.item_code,
|
"item_code": item.item_code,
|
||||||
@@ -107,11 +109,30 @@ def get_sales_order_items(sales_order: str) -> list[dict]:
|
|||||||
"qty": flt(item.qty),
|
"qty": flt(item.qty),
|
||||||
"rate": flt(item.rate),
|
"rate": flt(item.rate),
|
||||||
"amount": flt(item.amount),
|
"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
|
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()
|
@frappe.whitelist()
|
||||||
def make_proforma_invoice(
|
def make_proforma_invoice(
|
||||||
sales_order: str,
|
sales_order: str,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import json
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.utils import flt
|
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.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
@@ -92,6 +95,23 @@ class TestProformaInvoice(ERPNextTestSuite):
|
|||||||
self.assertEqual(proforma.status, "Cancelled")
|
self.assertEqual(proforma.status, "Cancelled")
|
||||||
self.assertEqual(proforma.proforma_pdf, pdf)
|
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):
|
def test_feature_toggle_is_enforced(self):
|
||||||
sales_order = make_sales_order(qty=10)
|
sales_order = make_sales_order(qty=10)
|
||||||
frappe.db.set_single_value("Selling Settings", "enable_proforma_invoice", 0)
|
frappe.db.set_single_value("Selling Settings", "enable_proforma_invoice", 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user