mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-14 15:11:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user