From 473c655cb251feda1e64849d1300d2d2e1b2097b Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 11:56:20 +0530 Subject: [PATCH] feat(selling): add amount-based proforma option Let a proforma be created by editing item amount instead of quantity, for value/advance-style proformas. - "Based On" (Quantity | Amount) on the proforma and the create dialog - Amount basis keeps the ordered qty and derives a rate so the line totals the entered amount; the PDF renders the same in-memory Sales Order copy - Proforma Invoice Item now stores rate and amount --- erpnext/public/js/sales_order_proforma.js | 39 ++++++++++- .../proforma_invoice/proforma_invoice.json | 9 +++ .../proforma_invoice/proforma_invoice.py | 69 ++++++++++++++----- .../proforma_invoice/test_proforma_invoice.py | 19 +++++ .../proforma_invoice_item.json | 18 +++++ .../proforma_invoice_item.py | 2 + 6 files changed, 134 insertions(+), 22 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index a550aac91c1..afd36f248e9 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -82,6 +82,14 @@ Object.assign(erpnext.proforma, { options: "Letter Head", }, { fieldname: "items_section", fieldtype: "Section Break", label: __("Items") }, + { + fieldname: "based_on", + fieldtype: "Select", + label: __("Based On"), + options: ["Quantity", "Amount"], + default: "Quantity", + onchange: () => this.toggle_basis(dialog), + }, { fieldname: "items", fieldtype: "Table", @@ -100,8 +108,22 @@ Object.assign(erpnext.proforma, { fieldtype: "Float", label: __("Qty"), 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"); + }, + }, + { + fieldname: "amount", + fieldtype: "Currency", + label: __("Amount"), + in_list_view: 1, + read_only: 1, }, { fieldname: "item_name", fieldtype: "Data", hidden: 1 }, + { fieldname: "rate", fieldtype: "Currency", hidden: 1 }, { fieldname: "so_detail", fieldtype: "Data", hidden: 1 }, ], }, @@ -113,13 +135,23 @@ Object.assign(erpnext.proforma, { dialog.show(); }, + // Both Qty and Amount columns stay visible; only the one matching the chosen basis is editable. + toggle_basis(dialog) { + const by_amount = dialog.get_value("based_on") === "Amount"; + const grid = dialog.get_field("items").grid; + grid.toggle_enable("qty", !by_amount); + grid.toggle_enable("amount", by_amount); + }, + create(frm, dialog, values) { + const by_amount = values.based_on === "Amount"; + const field = by_amount ? "amount" : "qty"; const items = (values.items || []) - .filter((row) => flt(row.qty) > 0) - .map((row) => ({ so_detail: row.so_detail, qty: row.qty })); + .filter((row) => flt(row[field]) > 0) + .map((row) => ({ so_detail: row.so_detail, [field]: row[field] })); if (!items.length) { - frappe.msgprint(__("Please enter a quantity for at least one item.")); + frappe.msgprint(__("Please enter a quantity or amount for at least one item.")); return; } @@ -128,6 +160,7 @@ Object.assign(erpnext.proforma, { args: { sales_order: frm.doc.name, items: JSON.stringify(items), + based_on: values.based_on, naming_series: values.naming_series, print_format: values.print_format, letter_head: values.letter_head, diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json index 385eb0ddf03..a8d6e035015 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json @@ -13,6 +13,7 @@ "proforma_date", "company", "currency", + "based_on", "items_section", "items", "total_qty", @@ -96,6 +97,14 @@ "print_hide": 1, "read_only": 1 }, + { + "default": "Quantity", + "fieldname": "based_on", + "fieldtype": "Select", + "label": "Based On", + "options": "Quantity\nAmount", + "read_only": 1 + }, { "fieldname": "items_section", "fieldtype": "Section Break", diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index 3b9cb189085..8d60aa89fe9 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -22,6 +22,7 @@ class ProformaInvoice(Document): ) amended_from: DF.Link | None + based_on: DF.Literal["Quantity", "Amount"] company: DF.Link currency: DF.Link | None customer: DF.Link | None @@ -64,16 +65,21 @@ class ProformaInvoice(Document): self.db_set("proforma_pdf", file.file_url) def render_pdf(self) -> dict: - """Render the proforma PDF from an in-memory, qty-adjusted copy of the Sales Order. + """Render the proforma PDF from an in-memory, adjusted copy of the Sales Order. The Sales Order copy is never saved; it exists only to reuse the standard tax/total - calculation and print format so the proforma shows accurate gross for the partial qty. + calculation and print format so the proforma shows the accurate gross. Each line's qty + and rate are set from the proforma (amount-based lines carry a derived rate), so the + recomputed amount matches whichever basis the proforma was created on. """ sales_order = frappe.get_doc("Sales Order", self.sales_order) - qty_by_detail = {item.so_detail: item.qty for item in self.items} - sales_order.items = [item for item in sales_order.items if item.name in qty_by_detail] + lines = {item.so_detail: item for item in self.items} + sales_order.items = [item for item in sales_order.items if item.name in lines] for item in sales_order.items: - item.qty = qty_by_detail[item.name] + item.qty = lines[item.name].qty + item.rate = lines[item.name].rate + item.discount_amount = 0 + item.discount_percentage = 0 sales_order.run_method("calculate_taxes_and_totals") sales_order.proforma_no = self.name sales_order.proforma_date = self.proforma_date @@ -99,6 +105,8 @@ def get_sales_order_items(sales_order: str) -> list[dict]: "uom": item.uom, "so_detail": item.name, "qty": flt(item.qty), + "rate": flt(item.rate), + "amount": flt(item.amount), } for item in sales_order_doc.items ] @@ -108,11 +116,16 @@ def get_sales_order_items(sales_order: str) -> list[dict]: def make_proforma_invoice( sales_order: str, items: str, + based_on: str = "Quantity", naming_series: str | None = None, print_format: str | None = None, letter_head: str | None = None, ) -> str: - """The sole creation path for a Proforma Invoice (the doctype is `in_create`).""" + """The sole creation path for a Proforma Invoice (the doctype is `in_create`). + + `based_on` decides what the user edited per line: "Quantity" (rate fixed, amount = qty x rate) + or "Amount" (qty fixed at ordered, rate derived so the line totals the entered amount). + """ validate_feature_enabled() selected = frappe.parse_json(items) sales_order_doc = frappe.get_doc("Sales Order", sales_order) @@ -120,6 +133,7 @@ def make_proforma_invoice( proforma = frappe.new_doc("Proforma Invoice") proforma.sales_order = sales_order + proforma.based_on = based_on if naming_series: proforma.naming_series = naming_series proforma.print_format = print_format or frappe.db.get_single_value( @@ -128,29 +142,46 @@ def make_proforma_invoice( proforma.letter_head = letter_head for row in selected: - qty = flt(row.get("qty")) so_item = so_items.get(row.get("so_detail")) - if qty <= 0 or not so_item: + if not so_item: continue - proforma.append( - "items", - { - "item_code": so_item.item_code, - "item_name": so_item.item_name, - "uom": so_item.uom, - "qty": qty, - "so_detail": so_item.name, - }, - ) + line = _proforma_line(so_item, based_on, row) + if line: + proforma.append("items", line) if not proforma.items: - frappe.throw(_("Please enter a quantity for at least one item.")) + frappe.throw(_("Please enter a quantity or amount for at least one item.")) proforma.insert() proforma.submit() return proforma.name +def _proforma_line(so_item, based_on: str, row: dict) -> dict | None: + if based_on == "Amount": + qty = flt(so_item.qty) + amount = flt(row.get("amount")) + if amount <= 0 or qty <= 0: + return None + rate = amount / qty + else: + qty = flt(row.get("qty")) + if qty <= 0: + return None + rate = flt(so_item.rate) + amount = qty * rate + + return { + "item_code": so_item.item_code, + "item_name": so_item.item_name, + "uom": so_item.uom, + "qty": qty, + "rate": rate, + "amount": amount, + "so_detail": so_item.name, + } + + @frappe.whitelist() def send_proforma_email(proforma_name: str, recipients: str) -> None: proforma = frappe.get_doc("Proforma Invoice", proforma_name) diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py index 534a6ba7c61..ae148a2cec0 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -61,6 +61,25 @@ class TestProformaInvoice(ERPNextTestSuite): # partial (4 of 10): net 400 + 10% tax = 440 self.assertEqual(flt(proforma.grand_total), 440) + def test_amount_based_proforma(self): + """Amount basis: qty stays ordered, rate is derived so the line totals the entered amount.""" + sales_order = make_sales_order(qty=10) # rate 100 -> ordered amount 1000 + so_detail = sales_order.items[0].name + + name = make_proforma_invoice( + sales_order.name, + json.dumps([{"so_detail": so_detail, "amount": 250}]), + based_on="Amount", + ) + proforma = frappe.get_doc("Proforma Invoice", name) + + self.assertEqual(proforma.based_on, "Amount") + item = proforma.items[0] + self.assertEqual(flt(item.qty), 10) + self.assertEqual(flt(item.rate), 25) + self.assertEqual(flt(item.amount), 250) + self.assertEqual(flt(proforma.grand_total), 250) + 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) diff --git a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json index cc5e4d834c1..d3ba6403a18 100644 --- a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json +++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json @@ -10,6 +10,8 @@ "column_break_qty", "qty", "uom", + "rate", + "amount", "so_detail" ], "fields": [ @@ -49,6 +51,22 @@ "options": "UOM", "read_only": 1 }, + { + "columns": 2, + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate", + "read_only": 1 + }, + { + "columns": 2, + "fieldname": "amount", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "read_only": 1 + }, { "fieldname": "so_detail", "fieldtype": "Data", diff --git a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py index b57c14a780d..86a326aa774 100644 --- a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py +++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py @@ -13,12 +13,14 @@ class ProformaInvoiceItem(Document): if TYPE_CHECKING: from frappe.types import DF + amount: DF.Currency item_code: DF.Link item_name: DF.Data | None parent: DF.Data parentfield: DF.Data parenttype: DF.Data qty: DF.Float + rate: DF.Currency so_detail: DF.Data | None uom: DF.Link | None # end: auto-generated types