From f9a09e1b3df1f21b4dace0e6b6465ab2d43cc108 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 11:51:03 +0530 Subject: [PATCH] refactor(selling): drop proforma quantity tracking Remove the pending/proforma-qty machinery: it only fit staged, incremental proformas and misrepresented the common whole-order / re-issued cases. - Drop proforma_qty from Sales Order Item and its submit/cancel write-back - Drop pending-qty aggregation and the over-qty soft warning - The create dialog now pre-fills the ordered qty (editable down) --- erpnext/public/js/sales_order_proforma.js | 21 ++------ .../proforma_invoice/proforma_invoice.py | 50 ++----------------- .../proforma_invoice/test_proforma_invoice.py | 36 ++----------- .../sales_order_item/sales_order_item.json | 9 ---- .../sales_order_item/sales_order_item.py | 1 - 5 files changed, 11 insertions(+), 106 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 1a9698a82c7..a550aac91c1 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -36,24 +36,24 @@ Object.assign(erpnext.proforma, { open_dialog(frm) { frappe.call({ - method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.get_pending_proforma_qty", + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.get_sales_order_items", args: { sales_order: frm.doc.name }, callback: (r) => this.show_dialog(frm, r.message || []), }); }, - show_dialog(frm, pending) { + show_dialog(frm, so_items) { frappe.model.with_doctype("Proforma Invoice", () => { const series = frappe.meta.get_docfield("Proforma Invoice", "naming_series"); frappe.db .get_single_value("Selling Settings", "default_proforma_print_format") .then((default_print_format) => { - this.build_dialog(frm, pending, series ? series.options : "", default_print_format); + this.build_dialog(frm, so_items, series ? series.options : "", default_print_format); }); }); }, - build_dialog(frm, pending, series_options, default_print_format) { + build_dialog(frm, so_items, series_options, default_print_format) { const dialog = new frappe.ui.Dialog({ title: __("Create Proforma Invoice"), size: "large", @@ -86,11 +86,7 @@ Object.assign(erpnext.proforma, { fieldname: "items", fieldtype: "Table", cannot_add_rows: true, - data: pending.map((row) => ({ - ...row, - qty: flt(row.pending_qty), - qty_summary: `${format_number(row.pending_qty)} / ${format_number(row.so_qty)}`, - })), + data: so_items.map((row) => ({ ...row })), fields: [ { fieldname: "item_code", @@ -99,13 +95,6 @@ Object.assign(erpnext.proforma, { read_only: 1, in_list_view: 1, }, - { - fieldname: "qty_summary", - fieldtype: "Data", - label: __("Pending"), - read_only: 1, - in_list_view: 1, - }, { fieldname: "qty", fieldtype: "Float", diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index 8333dde5664..3b9cb189085 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -4,7 +4,6 @@ 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 @@ -44,43 +43,19 @@ class ProformaInvoice(Document): def validate(self) -> None: validate_feature_enabled() self.set_total_qty() - self.warn_on_over_proforma_qty() def before_submit(self) -> None: self.status = "Issued" def on_submit(self) -> None: - self.update_proforma_qty_in_sales_order() self.generate_and_attach_pdf() def on_cancel(self) -> None: self.status = "Cancelled" - self.update_proforma_qty_in_sales_order() def set_total_qty(self) -> None: self.total_qty = sum(flt(item.qty) for item in self.items) - def warn_on_over_proforma_qty(self) -> None: - """Soft-warn (never block) if a line exceeds its pending proforma qty.""" - pending = {row["so_detail"]: row["pending_qty"] for row in get_pending_proforma_qty(self.sales_order)} - for item in self.items: - if flt(item.qty) > flt(pending.get(item.so_detail)) + 0.0001: - frappe.msgprint( - _("Qty {0} for {1} exceeds the pending proforma qty {2}.").format( - flt(item.qty), item.item_code, flt(pending.get(item.so_detail)) - ), - indicator="orange", - alert=True, - ) - - def update_proforma_qty_in_sales_order(self) -> None: - """Refresh the non-blocking, cosmetic proforma_qty counter on each SO item.""" - qty_map = get_proformed_qty_map(self.sales_order) - for name in frappe.get_all("Sales Order Item", filters={"parent": self.sales_order}, pluck="name"): - frappe.db.set_value( - "Sales Order Item", name, "proforma_qty", flt(qty_map.get(name)), update_modified=False - ) - def generate_and_attach_pdf(self) -> None: if self.proforma_pdf: return @@ -114,40 +89,21 @@ class ProformaInvoice(Document): @frappe.whitelist() -def get_pending_proforma_qty(sales_order: str) -> list[dict]: - """Per-SO-line pending proforma qty = ordered qty minus already issued proforma qty.""" +def get_sales_order_items(sales_order: str) -> list[dict]: + """Sales Order lines used to pre-fill the create-proforma dialog.""" sales_order_doc = frappe.get_doc("Sales Order", sales_order) - proformed = get_proformed_qty_map(sales_order) return [ { "item_code": item.item_code, "item_name": item.item_name, "uom": item.uom, "so_detail": item.name, - "so_qty": flt(item.qty), - "pending_qty": flt(item.qty) - flt(proformed.get(item.name)), + "qty": flt(item.qty), } for item in sales_order_doc.items ] -def get_proformed_qty_map(sales_order: str) -> dict[str, float]: - """Sum of issued (docstatus = 1) proforma qty 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")) - .where(item.parent.isin(proformas)) - .groupby(item.so_detail) - ).run(as_dict=True) - return {row.so_detail: flt(row.qty) 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 4b1ed6c7e3c..534a6ba7c61 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -6,10 +6,7 @@ import json import frappe from frappe.utils import flt -from erpnext.selling.doctype.proforma_invoice.proforma_invoice import ( - get_pending_proforma_qty, - make_proforma_invoice, -) +from erpnext.selling.doctype.proforma_invoice.proforma_invoice import make_proforma_invoice from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.tests.utils import ERPNextTestSuite @@ -24,7 +21,7 @@ class TestProformaInvoice(ERPNextTestSuite): return frappe.get_doc("Proforma Invoice", name) def test_partial_proforma_is_non_blocking(self): - """A proforma tracks its own qty but must not touch delivery/billing or the source SO.""" + """A proforma must not touch delivery/billing or the source Sales Order.""" sales_order = make_sales_order(qty=10) so_detail = sales_order.items[0].name @@ -41,36 +38,9 @@ class TestProformaInvoice(ERPNextTestSuite): self.assertEqual(flt(item.billed_amt), 0) self.assertEqual(flt(sales_order.per_delivered), 0) self.assertEqual(flt(sales_order.per_billed), 0) - # cosmetic counter set, ordered qty untouched (in-memory SO copy never persisted) - self.assertEqual(flt(item.proforma_qty), 4) + # ordered qty untouched (in-memory SO copy never persisted) self.assertEqual(flt(item.qty), 10) - def test_pending_qty_aggregates_and_excludes_cancelled(self): - sales_order = make_sales_order(qty=10) - so_detail = sales_order.items[0].name - - self.assertEqual(get_pending_proforma_qty(sales_order.name)[0]["pending_qty"], 10) - - first = self.create_proforma(sales_order, [(so_detail, 4)]) - self.assertEqual(get_pending_proforma_qty(sales_order.name)[0]["pending_qty"], 6) - - self.create_proforma(sales_order, [(so_detail, 3)]) - self.assertEqual(get_pending_proforma_qty(sales_order.name)[0]["pending_qty"], 3) - - # cancelling the first proforma reverses its contribution - first.cancel() - self.assertEqual(first.status, "Cancelled") - self.assertEqual(flt(frappe.db.get_value("Sales Order Item", so_detail, "proforma_qty")), 3) - self.assertEqual(get_pending_proforma_qty(sales_order.name)[0]["pending_qty"], 7) - - def test_over_qty_is_allowed_with_warning(self): - """Proforma qty above the pending qty is a soft warning, never a hard block.""" - sales_order = make_sales_order(qty=10) - so_detail = sales_order.items[0].name - - proforma = self.create_proforma(sales_order, [(so_detail, 15)]) - self.assertEqual(flt(proforma.items[0].qty), 15) - def test_taxes_scale_to_partial_qty(self): sales_order = make_sales_order(qty=10, do_not_submit=True) sales_order.append( diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 512c59ad07b..df5d4b76617 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -102,7 +102,6 @@ "column_break_69", "work_order_qty", "delivered_qty", - "proforma_qty", "produced_qty", "returned_qty", "picked_qty", @@ -703,14 +702,6 @@ "read_only": 1, "width": "100px" }, - { - "fieldname": "proforma_qty", - "fieldtype": "Float", - "label": "Proforma Qty", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "work_order_qty", "fieldtype": "Float", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index 386055eacc4..98298f22036 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -71,7 +71,6 @@ class SalesOrderItem(Document): price_list_rate: DF.Currency pricing_rules: DF.SmallText | None produced_qty: DF.Float - proforma_qty: DF.Float production_plan_qty: DF.Float project: DF.Link | None projected_qty: DF.Float