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)
This commit is contained in:
Nabin Hait
2026-07-17 11:51:03 +05:30
parent b0c53ac5a4
commit f9a09e1b3d
5 changed files with 11 additions and 106 deletions

View File

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

View File

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

View File

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

View File

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

View File

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