mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-11 05:31:48 +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,
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user