From fbdc1f5b1f29084d98b5cbe3e9e176da7d234309 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 16 Jul 2026 16:51:37 +0530 Subject: [PATCH 01/20] feat(selling): add proforma invoice settings and tracking field - Selling Settings: "Enable Proforma Invoice" toggle (opt-in) and a default proforma print format - Sales Order Item: non-blocking proforma_qty counter --- .../sales_order_item/sales_order_item.json | 9 ++++++++ .../sales_order_item/sales_order_item.py | 1 + .../selling_settings/selling_settings.json | 23 +++++++++++++++++++ .../selling_settings/selling_settings.py | 2 ++ 4 files changed, 35 insertions(+) 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 df5d4b76617..512c59ad07b 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -102,6 +102,7 @@ "column_break_69", "work_order_qty", "delivered_qty", + "proforma_qty", "produced_qty", "returned_qty", "picked_qty", @@ -702,6 +703,14 @@ "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 98298f22036..386055eacc4 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -71,6 +71,7 @@ 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 diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index ebae841dde9..b72a39c5ede 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -60,6 +60,9 @@ "allow_delivery_of_overproduced_qty", "column_break_mla9", "deliver_secondary_items", + "proforma_invoice_section", + "enable_proforma_invoice", + "default_proforma_print_format", "default_naming_tab", "transaction_naming_html" ], @@ -341,6 +344,26 @@ "fieldtype": "Check", "label": "Deliver secondary Items" }, + { + "fieldname": "proforma_invoice_section", + "fieldtype": "Section Break", + "label": "Proforma Invoice" + }, + { + "default": "0", + "description": "Allow issuing Proforma Invoices against a Sales Order.", + "fieldname": "enable_proforma_invoice", + "fieldtype": "Check", + "label": "Enable Proforma Invoice" + }, + { + "depends_on": "enable_proforma_invoice", + "description": "Default print format used when generating a Proforma Invoice PDF.", + "fieldname": "default_proforma_print_format", + "fieldtype": "Link", + "label": "Default Proforma Print Format", + "options": "Print Format" + }, { "fieldname": "customer_defaults_tab", "fieldtype": "Tab Break", diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py index bf8750cc1b8..66e4bf5d93a 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.py +++ b/erpnext/selling/doctype/selling_settings/selling_settings.py @@ -41,6 +41,7 @@ class SellingSettings(Document): blanket_order_allowance: DF.Float cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"] customer_group: DF.Link | None + default_proforma_print_format: DF.Link | None deliver_secondary_items: DF.Check dn_required: DF.Literal["No", "Yes"] dont_reserve_sales_order_qty_on_sales_return: DF.Check @@ -48,6 +49,7 @@ class SellingSettings(Document): editable_price_list_rate: DF.Check enable_cutoff_date_on_bulk_delivery_note_creation: DF.Check enable_discount_accounting: DF.Check + enable_proforma_invoice: DF.Check enable_tracking_sales_commissions: DF.Check enable_utm: DF.Check fallback_to_default_price_list: DF.Check From 07756f2bec3992d16fa5c9d6aed60cbd3577e6bd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 16 Jul 2026 16:51:48 +0530 Subject: [PATCH 02/20] feat(selling): add Proforma Invoice doctype and server API - Submittable, non-accounting Proforma Invoice + Proforma Invoice Item child doctype (in_create; posts no GL/stock, stores item + qty only) - Server API: pending-qty aggregation per Sales Order line (issued proformas only), make_proforma_invoice (sole creation path, gated on the settings toggle), PDF rendered from an in-memory qty-adjusted copy of the Sales Order and attached, send_proforma_email - Non-blocking proforma_qty write-back to the Sales Order on submit/cancel --- .../doctype/proforma_invoice/__init__.py | 0 .../proforma_invoice/proforma_invoice.json | 234 ++++++++++++++++++ .../proforma_invoice/proforma_invoice.py | 217 ++++++++++++++++ .../doctype/proforma_invoice_item/__init__.py | 0 .../proforma_invoice_item.json | 73 ++++++ .../proforma_invoice_item.py | 26 ++ 6 files changed, 550 insertions(+) create mode 100644 erpnext/selling/doctype/proforma_invoice/__init__.py create mode 100644 erpnext/selling/doctype/proforma_invoice/proforma_invoice.json create mode 100644 erpnext/selling/doctype/proforma_invoice/proforma_invoice.py create mode 100644 erpnext/selling/doctype/proforma_invoice_item/__init__.py create mode 100644 erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json create mode 100644 erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py diff --git a/erpnext/selling/doctype/proforma_invoice/__init__.py b/erpnext/selling/doctype/proforma_invoice/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json new file mode 100644 index 00000000000..385eb0ddf03 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json @@ -0,0 +1,234 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2026-07-16 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "naming_series", + "sales_order", + "customer", + "customer_name", + "column_break_header", + "proforma_date", + "company", + "currency", + "items_section", + "items", + "total_qty", + "grand_total", + "print_section", + "print_format", + "letter_head", + "proforma_pdf", + "status_section", + "status", + "sent_on", + "emailed_to", + "amended_from" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Series", + "no_copy": 1, + "options": "PRO-.YYYY.-", + "print_hide": 1, + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "sales_order", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Sales Order", + "options": "Sales Order", + "read_only": 1, + "reqd": 1 + }, + { + "fetch_from": "sales_order.customer", + "fieldname": "customer", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Customer", + "options": "Customer", + "read_only": 1 + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "in_global_search": 1, + "label": "Customer Name", + "read_only": 1 + }, + { + "fieldname": "column_break_header", + "fieldtype": "Column Break" + }, + { + "default": "Today", + "fieldname": "proforma_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "fetch_from": "sales_order.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1, + "reqd": 1 + }, + { + "fetch_from": "sales_order.currency", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "items_section", + "fieldtype": "Section Break", + "label": "Items" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Proforma Invoice Item", + "reqd": 1 + }, + { + "fieldname": "total_qty", + "fieldtype": "Float", + "label": "Total Quantity", + "read_only": 1 + }, + { + "fieldname": "grand_total", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Grand Total", + "options": "currency", + "read_only": 1 + }, + { + "fieldname": "print_section", + "fieldtype": "Section Break", + "label": "Print Settings" + }, + { + "fieldname": "print_format", + "fieldtype": "Link", + "label": "Print Format", + "options": "Print Format", + "read_only": 1 + }, + { + "fieldname": "letter_head", + "fieldtype": "Link", + "label": "Letter Head", + "options": "Letter Head", + "read_only": 1 + }, + { + "fieldname": "proforma_pdf", + "fieldtype": "Attach", + "label": "Proforma PDF", + "read_only": 1 + }, + { + "fieldname": "status_section", + "fieldtype": "Section Break" + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "no_copy": 1, + "options": "Draft\nIssued\nCancelled", + "read_only": 1 + }, + { + "fieldname": "sent_on", + "fieldtype": "Datetime", + "label": "Sent On", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "emailed_to", + "fieldtype": "Small Text", + "label": "Emailed To", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Amended From", + "no_copy": 1, + "options": "Proforma Invoice", + "print_hide": 1, + "read_only": 1 + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2026-07-16 00:00:00.000000", + "modified_by": "Administrator", + "module": "Selling", + "name": "Proforma Invoice", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "customer_name" +} diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py new file mode 100644 index 00000000000..8333dde5664 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -0,0 +1,217 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +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 + + +class ProformaInvoice(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.selling.doctype.proforma_invoice_item.proforma_invoice_item import ( + ProformaInvoiceItem, + ) + + amended_from: DF.Link | None + company: DF.Link + currency: DF.Link | None + customer: DF.Link | None + customer_name: DF.Data | None + emailed_to: DF.SmallText | None + grand_total: DF.Currency + items: DF.Table[ProformaInvoiceItem] + letter_head: DF.Link | None + naming_series: DF.Literal["PRO-.YYYY.-"] + print_format: DF.Link | None + proforma_date: DF.Date + proforma_pdf: DF.Attach | None + sales_order: DF.Link + sent_on: DF.Datetime | None + status: DF.Literal["Draft", "Issued", "Cancelled"] + total_qty: DF.Float + # end: auto-generated types + + 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 + printed = self.render_pdf() + file = save_file(printed["fname"], printed["fcontent"], self.doctype, self.name, is_private=1) + 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. + + 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. + """ + 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] + for item in sales_order.items: + item.qty = qty_by_detail[item.name] + sales_order.run_method("calculate_taxes_and_totals") + sales_order.proforma_no = self.name + sales_order.proforma_date = self.proforma_date + self.db_set("grand_total", sales_order.grand_total) + return frappe.attach_print( + "Sales Order", + sales_order.name, + doc=sales_order, + file_name=self.name, + print_format=self.print_format, + letterhead=self.letter_head, + ) + + +@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.""" + 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)), + } + 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, + items: str, + 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`).""" + validate_feature_enabled() + selected = frappe.parse_json(items) + sales_order_doc = frappe.get_doc("Sales Order", sales_order) + so_items = {item.name: item for item in sales_order_doc.items} + + proforma = frappe.new_doc("Proforma Invoice") + proforma.sales_order = sales_order + if naming_series: + proforma.naming_series = naming_series + proforma.print_format = print_format or frappe.db.get_single_value( + "Selling Settings", "default_proforma_print_format" + ) + 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: + 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, + }, + ) + + if not proforma.items: + frappe.throw(_("Please enter a quantity for at least one item.")) + + proforma.insert() + proforma.submit() + return proforma.name + + +@frappe.whitelist() +def send_proforma_email(proforma_name: str, recipients: str) -> None: + proforma = frappe.get_doc("Proforma Invoice", proforma_name) + if not proforma.proforma_pdf: + frappe.throw(_("This Proforma Invoice has no PDF to send.")) + + file_name = frappe.db.get_value("File", {"file_url": proforma.proforma_pdf}, "name") + frappe.sendmail( + recipients=[email.strip() for email in recipients.split(",") if email.strip()], + subject=_("Proforma Invoice {0}").format(proforma.name), + message=_("Please find attached the proforma invoice {0}.").format(proforma.name), + attachments=[{"fid": file_name}], + ) + proforma.db_set("sent_on", now()) + proforma.db_set("emailed_to", recipients) + + +def validate_feature_enabled() -> None: + if not frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice"): + frappe.throw(_("Proforma Invoice is not enabled in Selling Settings.")) diff --git a/erpnext/selling/doctype/proforma_invoice_item/__init__.py b/erpnext/selling/doctype/proforma_invoice_item/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json new file mode 100644 index 00000000000..cc5e4d834c1 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json @@ -0,0 +1,73 @@ +{ + "actions": [], + "creation": "2026-07-16 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item_code", + "item_name", + "column_break_qty", + "qty", + "uom", + "so_detail" + ], + "fields": [ + { + "columns": 4, + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fetch_from": "item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "column_break_qty", + "fieldtype": "Column Break" + }, + { + "columns": 2, + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Quantity", + "reqd": 1 + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "so_detail", + "fieldtype": "Data", + "label": "Sales Order Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-16 00:00:00.000000", + "modified_by": "Administrator", + "module": "Selling", + "name": "Proforma Invoice Item", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py new file mode 100644 index 00000000000..b57c14a780d --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py @@ -0,0 +1,26 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from frappe.model.document import Document + + +class ProformaInvoiceItem(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + item_code: DF.Link + item_name: DF.Data | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + qty: DF.Float + so_detail: DF.Data | None + uom: DF.Link | None + # end: auto-generated types + + pass From 65db3e374ba129a1a566f721c09e0a5409d0bb3c Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 16 Jul 2026 16:51:56 +0530 Subject: [PATCH 03/20] feat(selling): add Proforma Invoice print format Jinja print format on Sales Order, rendered against the in-memory qty-adjusted copy so taxes and totals reflect the partial quantity. --- .../print_format/proforma_invoice/__init__.py | 0 .../proforma_invoice/proforma_invoice.json | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 erpnext/selling/print_format/proforma_invoice/__init__.py create mode 100644 erpnext/selling/print_format/proforma_invoice/proforma_invoice.json diff --git a/erpnext/selling/print_format/proforma_invoice/__init__.py b/erpnext/selling/print_format/proforma_invoice/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json b/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json new file mode 100644 index 00000000000..52417275cdf --- /dev/null +++ b/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json @@ -0,0 +1,33 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "creation": "2026-07-16 00:00:00.000000", + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Sales Order", + "docstatus": 0, + "doctype": "Print Format", + "font_size": 0, + "html": "
\n\t\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
\n\t\t\t\t

{{ _(\"PROFORMA INVOICE\") }}

\n\t\t\t\t
{{ doc.company }}
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
{{ _(\"Proforma No\") }}{{ doc.proforma_no or doc.name }}
{{ _(\"Date\") }}{{ frappe.utils.formatdate(doc.proforma_date) }}
{{ _(\"Against Sales Order\") }}{{ doc.name }}
\n\t\t\t
\n\n\t
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
{{ _(\"Bill To\") }}
{{ doc.customer_name }}
\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t{% for row in doc.items %}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{% endfor %}\n\t\t\n\t
{{ _(\"Sr\") }}{{ _(\"Item\") }}{{ _(\"Qty\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}{{ row.item_code }}{% if row.item_name != row.item_code %}
{{ row.item_name }}{% endif %}
{{ row.get_formatted(\"qty\") }} {{ row.uom }}{{ row.get_formatted(\"rate\", doc) }}{{ row.get_formatted(\"amount\", doc) }}
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
{{ _(\"Net Total\") }}{{ doc.get_formatted(\"net_total\") }}
{{ tax.description }}{{ tax.get_formatted(\"tax_amount\", doc) }}
{{ _(\"Grand Total\") }}{{ doc.get_formatted(\"grand_total\") }}
\n\n\t
\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t
\n
\n", + "idx": 0, + "line_breaks": 0, + "margin_bottom": 15.0, + "margin_left": 15.0, + "margin_right": 15.0, + "margin_top": 15.0, + "modified": "2026-07-16 00:00:00.000000", + "modified_by": "Administrator", + "module": "Selling", + "name": "Proforma Invoice", + "owner": "Administrator", + "page_number": "Hide", + "pdf_generator": "wkhtmltopdf", + "print_format_builder": 0, + "print_format_builder_beta": 0, + "print_format_for": "", + "print_format_type": "Jinja", + "raw_printing": 0, + "show_section_headings": 0, + "standard": "Yes" +} From fdc8879ce19c8a98ad3aac6004edc8b29dc0beb5 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 16 Jul 2026 16:52:06 +0530 Subject: [PATCH 04/20] feat(selling): wire Proforma Invoice into Sales Order form - Create > Proforma Invoice dialog with naming series, print format and letter head selectors, and an item-wise pending-qty grid - Proforma tab listing issued proformas with inline view/email actions, shown only once at least one proforma exists - Register the client script and add the connections dashboard link --- erpnext/hooks.py | 1 + erpnext/public/js/sales_order_proforma.js | 248 ++++++++++++++++++ .../doctype/sales_order/sales_order.json | 13 + .../sales_order/sales_order_dashboard.py | 1 + 4 files changed, 263 insertions(+) create mode 100644 erpnext/public/js/sales_order_proforma.js diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 1806aaa9c0a..bbfe563ed5e 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -38,6 +38,7 @@ web_include_icons = [ doctype_js = { "Address": "public/js/address.js", + "Sales Order": "public/js/sales_order_proforma.js", "Communication": "public/js/communication.js", "Event": "public/js/event.js", "Newsletter": "public/js/newsletter.js", diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js new file mode 100644 index 00000000000..1a9698a82c7 --- /dev/null +++ b/erpnext/public/js/sales_order_proforma.js @@ -0,0 +1,248 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +frappe.ui.form.on("Sales Order", { + refresh(frm) { + erpnext.proforma.toggle_tab(frm, false); + if (frm.doc.docstatus !== 1) return; + + frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => { + if (!enabled) return; + + frm.add_custom_button( + __("Proforma Invoice"), + () => erpnext.proforma.open_dialog(frm), + __("Create") + ); + erpnext.proforma.render_list(frm); + }); + }, +}); + +frappe.provide("erpnext.proforma"); + +Object.assign(erpnext.proforma, { + toggle_tab(frm, show) { + // Toggle the Tab Break itself: set_df_property refreshes the field control but not the + // tab link, so drive the Tab object directly to actually show/hide the tab. + const tab = frm.get_field("proforma_html")?.tab; + if (tab) { + tab.df.hidden = show ? 0 : 1; + tab.toggle(show); + } else { + frm.set_df_property("proforma_tab", "hidden", show ? 0 : 1); + } + }, + + open_dialog(frm) { + frappe.call({ + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.get_pending_proforma_qty", + args: { sales_order: frm.doc.name }, + callback: (r) => this.show_dialog(frm, r.message || []), + }); + }, + + show_dialog(frm, pending) { + 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); + }); + }); + }, + + build_dialog(frm, pending, series_options, default_print_format) { + const dialog = new frappe.ui.Dialog({ + title: __("Create Proforma Invoice"), + size: "large", + fields: [ + { + fieldname: "naming_series", + fieldtype: "Select", + label: __("Series"), + options: series_options, + default: (series_options || "").split("\n")[0], + reqd: 1, + }, + { fieldname: "cb_series", fieldtype: "Column Break" }, + { + fieldname: "print_format", + fieldtype: "Link", + label: __("Print Format"), + options: "Print Format", + default: default_print_format, + get_query: () => ({ filters: { doc_type: "Sales Order" } }), + }, + { + fieldname: "letter_head", + fieldtype: "Link", + label: __("Letter Head"), + options: "Letter Head", + }, + { fieldname: "items_section", fieldtype: "Section Break", label: __("Items") }, + { + 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)}`, + })), + fields: [ + { + fieldname: "item_code", + fieldtype: "Data", + label: __("Item"), + 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", + label: __("Qty"), + in_list_view: 1, + }, + { fieldname: "item_name", fieldtype: "Data", hidden: 1 }, + { fieldname: "so_detail", fieldtype: "Data", hidden: 1 }, + ], + }, + ], + primary_action_label: __("Create"), + primary_action: (values) => this.create(frm, dialog, values), + }); + + dialog.show(); + }, + + create(frm, dialog, values) { + const items = (values.items || []) + .filter((row) => flt(row.qty) > 0) + .map((row) => ({ so_detail: row.so_detail, qty: row.qty })); + + if (!items.length) { + frappe.msgprint(__("Please enter a quantity for at least one item.")); + return; + } + + frappe.call({ + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.make_proforma_invoice", + args: { + sales_order: frm.doc.name, + items: JSON.stringify(items), + naming_series: values.naming_series, + print_format: values.print_format, + letter_head: values.letter_head, + }, + freeze: true, + freeze_message: __("Creating Proforma Invoice..."), + callback: (r) => { + if (!r.message) return; + dialog.hide(); + frappe.show_alert({ + message: __("Proforma Invoice {0} created", [r.message]), + indicator: "green", + }); + frm.reload_doc(); + }, + }); + }, + + render_list(frm) { + // EmbeddedList is a lazy bundle (not on the eager desk bundle), so pull it in first. + frappe.require("embedded_list.bundle.js", () => this.build_list(frm)); + }, + + build_list(frm) { + const wrapper = frm.get_field("proforma_html").$wrapper.empty(); + const list = new frappe.ui.EmbeddedList({ + wrapper, + doctype: "Proforma Invoice", + filters: { sales_order: frm.doc.name, docstatus: 1 }, + fields: ["name", "proforma_date", "grand_total", "status", "proforma_pdf", "sent_on", "currency"], + order_by: "creation desc", + empty_message: __("No proforma invoices yet."), + // Show the Proforma tab only once at least one proforma exists for this order. + after_render() { + erpnext.proforma.toggle_tab(frm, (this._all_data || []).length > 0); + }, + columns: [ + { + label: __("Proforma No"), + type: "link", + fieldname: "name", + route: (row) => ["Form", "Proforma Invoice", row.name], + }, + { + label: __("Date"), + fieldname: "proforma_date", + render: (row) => frappe.datetime.str_to_user(row.proforma_date), + }, + { + label: __("Grand Total"), + fieldname: "grand_total", + render: (row) => format_currency(row.grand_total, row.currency), + }, + { + label: __("Status"), + type: "badge", + fieldname: "status", + color: (row) => (row.status === "Issued" ? "green" : "gray"), + }, + { + type: "actions", + actions: [ + { + icon: "printer", + label: __("View PDF"), + action: (row) => row.proforma_pdf && window.open(row.proforma_pdf, "_blank"), + }, + { + icon: "mail", + label: __("Send Email"), + action: (row, refresh) => this.send_email(frm, row.name, refresh), + }, + ], + }, + ], + }); + list.refresh(); + }, + + send_email(frm, proforma_name, refresh) { + frappe.prompt( + [ + { + fieldname: "recipients", + fieldtype: "Data", + label: __("Recipients"), + reqd: 1, + default: frm.doc.contact_email, + description: __("Comma separated email addresses"), + }, + ], + (values) => { + frappe.call({ + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.send_proforma_email", + args: { proforma_name, recipients: values.recipients }, + freeze: true, + callback: () => { + frappe.show_alert({ message: __("Proforma emailed"), indicator: "green" }); + (refresh || (() => this.render_list(frm)))(); + }, + }); + }, + __("Send Proforma Invoice"), + __("Send") + ); + }, +}); diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 0b00a2d8613..9c8ed1bf649 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -178,6 +178,8 @@ "column_break_yvzv", "inter_company_order_reference", "party_account_currency", + "proforma_tab", + "proforma_html", "connections_tab" ], "fields": [ @@ -1526,6 +1528,17 @@ "fieldname": "column_break_49", "fieldtype": "Column Break" }, + { + "fieldname": "proforma_tab", + "fieldtype": "Tab Break", + "hidden": 1, + "label": "Proforma" + }, + { + "fieldname": "proforma_html", + "fieldtype": "HTML", + "label": "Proforma Invoices" + }, { "fieldname": "connections_tab", "fieldtype": "Tab Break", diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py index ea9c8d2f96e..f6767c533d0 100644 --- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py +++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py @@ -24,6 +24,7 @@ def get_data(): "label": _("Fulfillment"), "items": ["Sales Invoice", "Pick List", "Delivery Note", "Maintenance Visit"], }, + {"label": _("Proforma"), "items": ["Proforma Invoice"]}, {"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]}, {"label": _("Projects"), "items": ["Project"]}, {"label": _("Manufacturing"), "items": ["Work Order", "BOM", "Blanket Order"]}, From b0c53ac5a4adab1c8b5a4e844c05e15417ce3a7a Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 16 Jul 2026 16:52:17 +0530 Subject: [PATCH 05/20] test(selling): add Proforma Invoice tests Cover partial proforma being non-blocking on delivery/billing, pending-qty aggregation with cancelled proformas excluded, tax scaling to the partial qty, over-qty as a soft warning, and the settings gate. --- .../proforma_invoice/test_proforma_invoice.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py new file mode 100644 index 00000000000..4b1ed6c7e3c --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -0,0 +1,103 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +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.sales_order.test_sales_order import make_sales_order +from erpnext.tests.utils import ERPNextTestSuite + + +class TestProformaInvoice(ERPNextTestSuite): + def setUp(self): + frappe.db.set_single_value("Selling Settings", "enable_proforma_invoice", 1) + + def create_proforma(self, sales_order, lines, **kwargs): + items = [{"so_detail": so_detail, "qty": qty} for so_detail, qty in lines] + name = make_proforma_invoice(sales_order.name, json.dumps(items), **kwargs) + 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.""" + sales_order = make_sales_order(qty=10) + so_detail = sales_order.items[0].name + + proforma = self.create_proforma(sales_order, [(so_detail, 4)]) + + self.assertEqual(proforma.status, "Issued") + self.assertEqual(proforma.docstatus, 1) + self.assertTrue(proforma.proforma_pdf, "PDF should be generated and attached") + + sales_order.reload() + item = sales_order.items[0] + # fulfillment untouched + self.assertEqual(flt(item.delivered_qty), 0) + 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) + 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( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account CST - _TC", + "description": "CST", + "rate": 10, + }, + ) + sales_order.submit() + + # full order: net 1000 + 10% tax = 1100 + self.assertEqual(flt(sales_order.grand_total), 1100) + + proforma = self.create_proforma(sales_order, [(sales_order.items[0].name, 4)]) + # partial (4 of 10): net 400 + 10% tax = 440 + self.assertEqual(flt(proforma.grand_total), 440) + + 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) + + self.assertRaises( + frappe.ValidationError, + self.create_proforma, + sales_order, + [(sales_order.items[0].name, 4)], + ) From f9a09e1b3df1f21b4dace0e6b6465ab2d43cc108 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 11:51:03 +0530 Subject: [PATCH 06/20] 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 From 473c655cb251feda1e64849d1300d2d2e1b2097b Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 11:56:20 +0530 Subject: [PATCH 07/20] 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 From f711375885f44e9e0bac3d7c842fb017ca45aec2 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 11:58:51 +0530 Subject: [PATCH 08/20] feat(selling): keep cancelled proformas visible with their PDF Cancelling a proforma should void it, not erase history. - Persist the Cancelled status on cancel (db_set) and keep the PDF attached - Proforma tab now lists cancelled proformas with a red status badge, so the voided document and its PDF stay reachable for audit --- erpnext/public/js/sales_order_proforma.js | 5 +++-- .../doctype/proforma_invoice/proforma_invoice.py | 2 +- .../proforma_invoice/test_proforma_invoice.py | 12 ++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index afd36f248e9..3e4677e4f57 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -189,7 +189,8 @@ Object.assign(erpnext.proforma, { const list = new frappe.ui.EmbeddedList({ wrapper, doctype: "Proforma Invoice", - filters: { sales_order: frm.doc.name, docstatus: 1 }, + // Include cancelled (docstatus 2) so voided proformas stay visible for audit. + filters: { sales_order: frm.doc.name, docstatus: ["in", [1, 2]] }, fields: ["name", "proforma_date", "grand_total", "status", "proforma_pdf", "sent_on", "currency"], order_by: "creation desc", empty_message: __("No proforma invoices yet."), @@ -218,7 +219,7 @@ Object.assign(erpnext.proforma, { label: __("Status"), type: "badge", fieldname: "status", - color: (row) => (row.status === "Issued" ? "green" : "gray"), + color: (row) => (row.status === "Cancelled" ? "red" : "green"), }, { type: "actions", diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index 8d60aa89fe9..9569db2d5c1 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -52,7 +52,7 @@ class ProformaInvoice(Document): self.generate_and_attach_pdf() def on_cancel(self) -> None: - self.status = "Cancelled" + self.db_set("status", "Cancelled") def set_total_qty(self) -> None: self.total_qty = sum(flt(item.qty) for item in self.items) diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py index ae148a2cec0..c48de3f041c 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -80,6 +80,18 @@ class TestProformaInvoice(ERPNextTestSuite): self.assertEqual(flt(item.amount), 250) self.assertEqual(flt(proforma.grand_total), 250) + def test_cancelled_proforma_keeps_pdf(self): + """Cancelling voids the proforma but keeps its PDF and status for the audit trail.""" + sales_order = make_sales_order(qty=10) + proforma = self.create_proforma(sales_order, [(sales_order.items[0].name, 4)]) + pdf = proforma.proforma_pdf + self.assertTrue(pdf) + + proforma.cancel() + proforma.reload() + self.assertEqual(proforma.status, "Cancelled") + self.assertEqual(proforma.proforma_pdf, pdf) + 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) From 4177101ac0e7252a51761d072d80c7998a124e94 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 12:59:41 +0530 Subject: [PATCH 09/20] feat(selling): warn when total proforma exceeds the ordered qty/amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- erpnext/public/js/sales_order_proforma.js | 45 +++++++++++++++++-- .../proforma_invoice/proforma_invoice.py | 23 +++++++++- .../proforma_invoice/test_proforma_invoice.py | 22 ++++++++- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 3e4677e4f57..9d1c9c3b294 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -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( + `
${__( + "Total proforma {0} (including past proformas) exceeds the ordered {0} for: {1}", + [basis, frappe.utils.escape_html(exceeded.join(", "))] + )}
` + ); }, create(frm, dialog, values) { diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index 9569db2d5c1..77a5430b826 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -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, diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py index c48de3f041c..bcca75b897c 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -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) From 77540403ab152e8d2ee78abe8921e7b102ccbea7 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 14:11:26 +0530 Subject: [PATCH 10/20] feat(selling): prefill remaining proforma qty/amount and reorder Create button - Pre-fill each line with the remaining (ordered minus already proformed) qty and amount, so the default no longer trips the excess warning - Place the Proforma Invoice action after the standard Create options --- erpnext/public/js/sales_order_proforma.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 9d1c9c3b294..fabbb67df69 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -9,11 +9,14 @@ frappe.ui.form.on("Sales Order", { frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => { if (!enabled) return; - frm.add_custom_button( - __("Proforma Invoice"), - () => erpnext.proforma.open_dialog(frm), - __("Create") - ); + // Defer so the button lands after the standard Create options, not before them. + setTimeout(() => { + frm.add_custom_button( + __("Proforma Invoice"), + () => erpnext.proforma.open_dialog(frm), + __("Create") + ); + }, 0); erpnext.proforma.render_list(frm); }); }, @@ -94,7 +97,12 @@ Object.assign(erpnext.proforma, { fieldname: "items", fieldtype: "Table", cannot_add_rows: true, - data: so_items.map((row) => ({ ...row })), + // Pre-fill the remaining (ordered minus already-proformed) for each basis. + data: so_items.map((row) => ({ + ...row, + qty: Math.max(0, flt(row.qty) - flt(row.proformed_qty)), + amount: Math.max(0, flt(row.amount) - flt(row.proformed_amount)), + })), fields: [ { fieldname: "item_code", From 2242f1b2303536b3c1be8ce5671833a0a232517e Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 17:37:01 +0530 Subject: [PATCH 11/20] feat(selling): make qty editable in amount-based proforma In Amount basis, both qty and amount are now user-entered and the rate is derived from them (rate = amount / qty). Previously qty was forced to the ordered qty, which ignored an edited qty when switching basis. --- erpnext/public/js/sales_order_proforma.js | 18 +++++++++++------- .../proforma_invoice/proforma_invoice.py | 3 ++- .../proforma_invoice/test_proforma_invoice.py | 10 +++++----- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index fabbb67df69..3363ffefc66 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -117,8 +117,9 @@ Object.assign(erpnext.proforma, { label: __("Qty"), in_list_view: 1, onchange: function () { - // Keep the read-only Amount in sync while editing qty (Quantity basis). - if (this.doc) { + // In Quantity basis, Amount is derived (qty x rate). In Amount basis + // both are user-entered, so leave Amount alone. + if (this.doc && dialog.get_value("based_on") === "Quantity") { this.doc.amount = flt(this.doc.qty) * flt(this.doc.rate); this.grid_row?.refresh_field("amount"); } @@ -149,11 +150,11 @@ Object.assign(erpnext.proforma, { this.update_warning(dialog); }, - // Both Qty and Amount columns stay visible; only the one matching the chosen basis is editable. + // Qty is always editable; Amount is editable only in Amount basis (else it is derived). 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("qty", true); grid.toggle_enable("amount", by_amount); this.update_warning(dialog); }, @@ -192,10 +193,13 @@ Object.assign(erpnext.proforma, { 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[field]) > 0) - .map((row) => ({ so_detail: row.so_detail, [field]: row[field] })); + .filter((row) => flt(by_amount ? row.amount : row.qty) > 0) + .map((row) => + by_amount + ? { so_detail: row.so_detail, qty: row.qty, amount: row.amount } + : { so_detail: row.so_detail, qty: row.qty } + ); if (!items.length) { frappe.msgprint(__("Please enter a quantity or amount for at least one item.")); diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index 77a5430b826..956dcbc0b10 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -180,7 +180,8 @@ def make_proforma_invoice( def _proforma_line(so_item, based_on: str, row: dict) -> dict | None: if based_on == "Amount": - qty = flt(so_item.qty) + # Amount basis: both qty and amount are user-entered; the rate is derived. + qty = flt(row.get("qty")) amount = flt(row.get("amount")) if amount <= 0 or qty <= 0: return None diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py index bcca75b897c..5ab30691208 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -65,21 +65,21 @@ class TestProformaInvoice(ERPNextTestSuite): 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 + """Amount basis: qty and amount are both entered; the rate is derived from them.""" + sales_order = make_sales_order(qty=10) # rate 100 so_detail = sales_order.items[0].name name = make_proforma_invoice( sales_order.name, - json.dumps([{"so_detail": so_detail, "amount": 250}]), + json.dumps([{"so_detail": so_detail, "qty": 5, "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.qty), 5) + self.assertEqual(flt(item.rate), 50) # 250 / 5 self.assertEqual(flt(item.amount), 250) self.assertEqual(flt(proforma.grand_total), 250) From 1da4657530e15678ce221fca0f9dab8ee8473fe3 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 17:44:04 +0530 Subject: [PATCH 12/20] feat(selling): option to hide item qty on amount-based proforma print Add a "Hide Item Quantity in Print" option (Amount basis only) that omits the qty and rate columns from the printed proforma, for a clean value-based document that shows only item and amount. --- erpnext/public/js/sales_order_proforma.js | 7 +++++++ .../proforma_invoice/proforma_invoice.json | 10 +++++++++ .../proforma_invoice/proforma_invoice.py | 7 ++++++- .../proforma_invoice/test_proforma_invoice.py | 21 +++++++++++++++++++ .../proforma_invoice/proforma_invoice.json | 2 +- 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 3363ffefc66..314e0e803ec 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -93,6 +93,12 @@ Object.assign(erpnext.proforma, { default: "Quantity", onchange: () => this.toggle_basis(dialog), }, + { + fieldname: "hide_item_qty", + fieldtype: "Check", + label: __("Hide Item Quantity in Print"), + depends_on: 'eval:doc.based_on=="Amount"', + }, { fieldname: "items", fieldtype: "Table", @@ -212,6 +218,7 @@ Object.assign(erpnext.proforma, { sales_order: frm.doc.name, items: JSON.stringify(items), based_on: values.based_on, + hide_item_qty: values.hide_item_qty ? 1 : 0, 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 a8d6e035015..3b698d6d1bb 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json @@ -14,6 +14,7 @@ "company", "currency", "based_on", + "hide_item_qty", "items_section", "items", "total_qty", @@ -105,6 +106,15 @@ "options": "Quantity\nAmount", "read_only": 1 }, + { + "default": "0", + "depends_on": "eval:doc.based_on==\"Amount\"", + "description": "Hide the item quantity and rate on the printed proforma.", + "fieldname": "hide_item_qty", + "fieldtype": "Check", + "label": "Hide Item Quantity in Print", + "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 956dcbc0b10..d633c7426d0 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -30,6 +30,7 @@ class ProformaInvoice(Document): customer_name: DF.Data | None emailed_to: DF.SmallText | None grand_total: DF.Currency + hide_item_qty: DF.Check items: DF.Table[ProformaInvoiceItem] letter_head: DF.Link | None naming_series: DF.Literal["PRO-.YYYY.-"] @@ -84,6 +85,7 @@ class ProformaInvoice(Document): sales_order.run_method("calculate_taxes_and_totals") sales_order.proforma_no = self.name sales_order.proforma_date = self.proforma_date + sales_order.hide_item_qty = self.hide_item_qty self.db_set("grand_total", sales_order.grand_total) return frappe.attach_print( "Sales Order", @@ -138,6 +140,7 @@ def make_proforma_invoice( sales_order: str, items: str, based_on: str = "Quantity", + hide_item_qty: bool | int = 0, naming_series: str | None = None, print_format: str | None = None, letter_head: str | None = None, @@ -145,7 +148,8 @@ def make_proforma_invoice( """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). + or "Amount" (both qty and amount entered, rate derived). `hide_item_qty` (Amount basis only) + hides the qty and rate on the printed proforma for a clean value-based document. """ validate_feature_enabled() selected = frappe.parse_json(items) @@ -155,6 +159,7 @@ def make_proforma_invoice( proforma = frappe.new_doc("Proforma Invoice") proforma.sales_order = sales_order proforma.based_on = based_on + proforma.hide_item_qty = 1 if (based_on == "Amount" and int(hide_item_qty or 0)) else 0 if naming_series: proforma.naming_series = naming_series proforma.print_format = print_format or frappe.db.get_single_value( diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py index 5ab30691208..a5e1789bb85 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -112,6 +112,27 @@ class TestProformaInvoice(ERPNextTestSuite): self.assertEqual(flt(data["proformed_qty"]), 3) self.assertEqual(flt(data["proformed_amount"]), 300) + def test_hide_item_qty_only_applies_to_amount_basis(self): + sales_order = make_sales_order(qty=10) + so_detail = sales_order.items[0].name + + amount_based = make_proforma_invoice( + sales_order.name, + json.dumps([{"so_detail": so_detail, "qty": 5, "amount": 250}]), + based_on="Amount", + hide_item_qty=1, + ) + self.assertEqual(frappe.db.get_value("Proforma Invoice", amount_based, "hide_item_qty"), 1) + + # ignored outside Amount basis + qty_based = make_proforma_invoice( + sales_order.name, + json.dumps([{"so_detail": so_detail, "qty": 4}]), + based_on="Quantity", + hide_item_qty=1, + ) + self.assertEqual(frappe.db.get_value("Proforma Invoice", qty_based, "hide_item_qty"), 0) + 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/print_format/proforma_invoice/proforma_invoice.json b/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json index 52417275cdf..8ef8184928d 100644 --- a/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json +++ b/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json @@ -9,7 +9,7 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 0, - "html": "
\n\t\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
\n\t\t\t\t

{{ _(\"PROFORMA INVOICE\") }}

\n\t\t\t\t
{{ doc.company }}
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
{{ _(\"Proforma No\") }}{{ doc.proforma_no or doc.name }}
{{ _(\"Date\") }}{{ frappe.utils.formatdate(doc.proforma_date) }}
{{ _(\"Against Sales Order\") }}{{ doc.name }}
\n\t\t\t
\n\n\t
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
{{ _(\"Bill To\") }}
{{ doc.customer_name }}
\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t{% for row in doc.items %}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{% endfor %}\n\t\t\n\t
{{ _(\"Sr\") }}{{ _(\"Item\") }}{{ _(\"Qty\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}{{ row.item_code }}{% if row.item_name != row.item_code %}
{{ row.item_name }}{% endif %}
{{ row.get_formatted(\"qty\") }} {{ row.uom }}{{ row.get_formatted(\"rate\", doc) }}{{ row.get_formatted(\"amount\", doc) }}
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
{{ _(\"Net Total\") }}{{ doc.get_formatted(\"net_total\") }}
{{ tax.description }}{{ tax.get_formatted(\"tax_amount\", doc) }}
{{ _(\"Grand Total\") }}{{ doc.get_formatted(\"grand_total\") }}
\n\n\t
\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t
\n
\n", + "html": "
\n\t\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
\n\t\t\t\t

{{ _(\"PROFORMA INVOICE\") }}

\n\t\t\t\t
{{ doc.company }}
\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t
{{ _(\"Proforma No\") }}{{ doc.proforma_no or doc.name }}
{{ _(\"Date\") }}{{ frappe.utils.formatdate(doc.proforma_date) }}
{{ _(\"Against Sales Order\") }}{{ doc.name }}
\n\t\t\t
\n\n\t
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
{{ _(\"Bill To\") }}
{{ doc.customer_name }}
\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% if not doc.hide_item_qty %}{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}{% endif %}\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t{% for row in doc.items %}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t{% if not doc.hide_item_qty %}{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}{% endif %}\n\t\t\t\t\n\t\t\t\n\t\t\t{% endfor %}\n\t\t\n\t
{{ _(\"Sr\") }}{{ _(\"Item\") }}{{ _(\"Qty\") }}{{ _(\"Rate\") }}{{ _(\"Amount\") }}
{{ loop.index }}{{ row.item_code }}{% if row.item_name != row.item_code %}
{{ row.item_name }}{% endif %}
{{ row.get_formatted(\"qty\") }} {{ row.uom }}{{ row.get_formatted(\"rate\", doc) }}{{ row.get_formatted(\"amount\", doc) }}
\n\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t
{{ _(\"Net Total\") }}{{ doc.get_formatted(\"net_total\") }}
{{ tax.description }}{{ tax.get_formatted(\"tax_amount\", doc) }}
{{ _(\"Grand Total\") }}{{ doc.get_formatted(\"grand_total\") }}
\n\n\t
\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t
\n
\n", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, From 853c1d8986465179e34bf4a1fd735c600bec7314 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 17:54:45 +0530 Subject: [PATCH 13/20] feat(selling): add a New button below the proforma listing Place a "+ New" button under the Proforma tab list to create another proforma without leaving the tab. --- erpnext/public/js/sales_order_proforma.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 314e0e803ec..9e95fba8088 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -243,9 +243,9 @@ Object.assign(erpnext.proforma, { }, build_list(frm) { - const wrapper = frm.get_field("proforma_html").$wrapper.empty(); + const container = frm.get_field("proforma_html").$wrapper.empty(); const list = new frappe.ui.EmbeddedList({ - wrapper, + wrapper: $("
").appendTo(container), doctype: "Proforma Invoice", // Include cancelled (docstatus 2) so voided proformas stay visible for audit. filters: { sales_order: frm.doc.name, docstatus: ["in", [1, 2]] }, @@ -297,6 +297,18 @@ Object.assign(erpnext.proforma, { ], }); list.refresh(); + + frappe.ui + .button({ + label: __("New Proforma"), + icon: "plus", + variant: "subtle", + size: "sm", + onclick: () => this.open_dialog(frm), + }) + .appendTo( + $('
').appendTo(container) + ); }, send_email(frm, proforma_name, refresh) { From 63607e91fd52caaf99de0916867a32323d57080f Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 20:04:18 +0530 Subject: [PATCH 14/20] feat(selling): open the Proforma tab after creating a proforma Flag the form on create and activate the Proforma tab once the reloaded form has rendered the list, so the new proforma is shown immediately. --- erpnext/public/js/sales_order_proforma.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 9e95fba8088..71d382b23cf 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -232,6 +232,8 @@ Object.assign(erpnext.proforma, { message: __("Proforma Invoice {0} created", [r.message]), indicator: "green", }); + // Open the Proforma tab once the reloaded form has rendered the list. + frm._activate_proforma_tab = true; frm.reload_doc(); }, }); @@ -254,7 +256,12 @@ Object.assign(erpnext.proforma, { empty_message: __("No proforma invoices yet."), // Show the Proforma tab only once at least one proforma exists for this order. after_render() { - erpnext.proforma.toggle_tab(frm, (this._all_data || []).length > 0); + const has_proformas = (this._all_data || []).length > 0; + erpnext.proforma.toggle_tab(frm, has_proformas); + if (has_proformas && frm._activate_proforma_tab) { + frm._activate_proforma_tab = false; + frm.get_field("proforma_html")?.tab?.set_active(); + } }, columns: [ { From 2a9603fcd65fe4f03d010250527ff8bacc9f1252 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 17 Jul 2026 20:12:37 +0530 Subject: [PATCH 15/20] style(selling): organise Proforma Invoice form into sections Group fields with section and column breaks: Details (two columns), Items with right-aligned totals, Print Settings (two columns), and Status (two columns). --- .../proforma_invoice/proforma_invoice.json | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json index 3b698d6d1bb..14ef90efacb 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json @@ -5,6 +5,7 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "details_section", "naming_series", "sales_order", "customer", @@ -17,19 +18,28 @@ "hide_item_qty", "items_section", "items", + "totals_section", + "column_break_totals", "total_qty", "grand_total", "print_section", "print_format", "letter_head", + "column_break_print", "proforma_pdf", "status_section", "status", "sent_on", + "column_break_status", "emailed_to", "amended_from" ], "fields": [ + { + "fieldname": "details_section", + "fieldtype": "Section Break", + "label": "Details" + }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -127,6 +137,14 @@ "options": "Proforma Invoice Item", "reqd": 1 }, + { + "fieldname": "totals_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_totals", + "fieldtype": "Column Break" + }, { "fieldname": "total_qty", "fieldtype": "Float", @@ -160,6 +178,10 @@ "options": "Letter Head", "read_only": 1 }, + { + "fieldname": "column_break_print", + "fieldtype": "Column Break" + }, { "fieldname": "proforma_pdf", "fieldtype": "Attach", @@ -168,7 +190,8 @@ }, { "fieldname": "status_section", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Status" }, { "default": "Draft", @@ -188,6 +211,10 @@ "no_copy": 1, "read_only": 1 }, + { + "fieldname": "column_break_status", + "fieldtype": "Column Break" + }, { "fieldname": "emailed_to", "fieldtype": "Small Text", From 7314cedd53ca161aa58b628ad46fcfadb81d47fd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 19 Jul 2026 11:21:41 +0530 Subject: [PATCH 16/20] feat(selling): surface proforma settings in Selling Settings tabs - Move the Proforma Invoice settings section from the Subcontracting Inward tab to the Transaction tab - List Proforma Invoice in the Document Naming tab so its naming series can be configured there --- .../selling/doctype/selling_settings/selling_settings.js | 1 + .../selling/doctype/selling_settings/selling_settings.json | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.js b/erpnext/selling/doctype/selling_settings/selling_settings.js index 9ffe9390a24..5f7ee27ee95 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.js +++ b/erpnext/selling/doctype/selling_settings/selling_settings.js @@ -50,6 +50,7 @@ function get_transactions(frm) { { label: __("Sales Order"), doctype: "Sales Order" }, { label: __("Sales Invoice"), doctype: "Sales Invoice" }, { label: __("Delivery Note"), doctype: "Delivery Note" }, + { label: __("Proforma Invoice"), doctype: "Proforma Invoice" }, ]; if (frm.doc.cust_master_name !== "Naming Series") { diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index b72a39c5ede..4cd5c6d2625 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -48,6 +48,9 @@ "allow_zero_qty_in_sales_order", "blanket_orders_section", "blanket_order_allowance", + "proforma_invoice_section", + "enable_proforma_invoice", + "default_proforma_print_format", "advanced_features_tab", "section_break_avhb", "enable_tracking_sales_commissions", @@ -60,9 +63,6 @@ "allow_delivery_of_overproduced_qty", "column_break_mla9", "deliver_secondary_items", - "proforma_invoice_section", - "enable_proforma_invoice", - "default_proforma_print_format", "default_naming_tab", "transaction_naming_html" ], From 654c9e6ad8a017103c8cde659f8837635b211d10 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 19 Jul 2026 11:25:40 +0530 Subject: [PATCH 17/20] style(selling): form-builder layout tweaks and label rename - Proforma Invoice form: place Grand Total beside Total Quantity (column break) and minor field reorder - Rename the tab button to "New Proforma Invoice" - Add the Proforma Invoice client-script scaffold --- erpnext/public/js/sales_order_proforma.js | 2 +- .../doctype/proforma_invoice/proforma_invoice.js | 8 ++++++++ .../doctype/proforma_invoice/proforma_invoice.json | 12 +++++++++--- .../doctype/proforma_invoice/proforma_invoice.py | 4 +--- 4 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 erpnext/selling/doctype/proforma_invoice/proforma_invoice.js diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 71d382b23cf..3d24cc22bb2 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -307,7 +307,7 @@ Object.assign(erpnext.proforma, { frappe.ui .button({ - label: __("New Proforma"), + label: __("New Proforma Invoice"), icon: "plus", variant: "subtle", size: "sm", diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.js b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.js new file mode 100644 index 00000000000..a8f08572bc5 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Proforma Invoice", { +// refresh(frm) { + +// }, +// }); diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json index 14ef90efacb..0a45df89088 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json @@ -1,7 +1,7 @@ { "actions": [], "autoname": "naming_series:", - "creation": "2026-07-16 00:00:00.000000", + "creation": "2026-07-16 00:00:00", "doctype": "DocType", "engine": "InnoDB", "field_order": [ @@ -10,9 +10,9 @@ "sales_order", "customer", "customer_name", + "company", "column_break_header", "proforma_date", - "company", "currency", "based_on", "hide_item_qty", @@ -21,6 +21,7 @@ "totals_section", "column_break_totals", "total_qty", + "column_break_fukr", "grand_total", "print_section", "print_format", @@ -231,13 +232,17 @@ "options": "Proforma Invoice", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "column_break_fukr", + "fieldtype": "Column Break" } ], "in_create": 1, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-07-16 00:00:00.000000", + "modified": "2026-07-19 11:15:50.347119", "modified_by": "Administrator", "module": "Selling", "name": "Proforma Invoice", @@ -273,6 +278,7 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index d633c7426d0..ac9d1bc046e 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -18,9 +18,7 @@ class ProformaInvoice(Document): if TYPE_CHECKING: from frappe.types import DF - from erpnext.selling.doctype.proforma_invoice_item.proforma_invoice_item import ( - ProformaInvoiceItem, - ) + from erpnext.selling.doctype.proforma_invoice_item.proforma_invoice_item import ProformaInvoiceItem amended_from: DF.Link | None based_on: DF.Literal["Quantity", "Amount"] From e932105ee3912117ba78b65d46586152292994f3 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 19 Jul 2026 12:26:34 +0530 Subject: [PATCH 18/20] fix(selling): recompute proforma amount for all rows on qty change Refreshing a single grid row only updates the active row, so the derived amount for rows after the edited one went stale. Recompute every row's amount from qty x rate and re-render the grid. --- erpnext/public/js/sales_order_proforma.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 3d24cc22bb2..5989d32fe0d 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -123,11 +123,15 @@ Object.assign(erpnext.proforma, { label: __("Qty"), in_list_view: 1, onchange: function () { - // In Quantity basis, Amount is derived (qty x rate). In Amount basis - // both are user-entered, so leave Amount alone. - if (this.doc && dialog.get_value("based_on") === "Quantity") { - this.doc.amount = flt(this.doc.qty) * flt(this.doc.rate); - this.grid_row?.refresh_field("amount"); + // In Quantity basis, Amount is derived (qty x rate). Recompute across + // all rows and re-render — refreshing a single row only updates the + // active one, so rows beyond the edited one would go stale. + if (dialog.get_value("based_on") === "Quantity") { + const grid = dialog.get_field("items").grid; + (grid.grid_rows || []).forEach((row) => { + if (row.doc) row.doc.amount = flt(row.doc.qty) * flt(row.doc.rate); + }); + grid.refresh(); } erpnext.proforma.update_warning(dialog); }, From e4fb5ed3c4a08b87d2a689ee79721a4c94c57d19 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 23 Jul 2026 18:59:08 +0530 Subject: [PATCH 19/20] fix(selling): guard proforma against unsubmitted SO and missing PDF Address review findings: - make_proforma_invoice: reject a non-submitted Sales Order (the whitelisted endpoint was previously only JS-gated on docstatus) - send_proforma_email: throw a clear error when the attached PDF File is missing instead of passing a null fid to sendmail --- .../doctype/proforma_invoice/proforma_invoice.py | 4 ++++ .../doctype/proforma_invoice/test_proforma_invoice.py | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index ac9d1bc046e..cd7f25e5658 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -152,6 +152,8 @@ def make_proforma_invoice( validate_feature_enabled() selected = frappe.parse_json(items) sales_order_doc = frappe.get_doc("Sales Order", sales_order) + if sales_order_doc.docstatus != 1: + frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order.")) so_items = {item.name: item for item in sales_order_doc.items} proforma = frappe.new_doc("Proforma Invoice") @@ -214,6 +216,8 @@ def send_proforma_email(proforma_name: str, recipients: str) -> None: frappe.throw(_("This Proforma Invoice has no PDF to send.")) file_name = frappe.db.get_value("File", {"file_url": proforma.proforma_pdf}, "name") + if not file_name: + frappe.throw(_("The attached PDF file could not be found.")) frappe.sendmail( recipients=[email.strip() for email in recipients.split(",") if email.strip()], subject=_("Proforma Invoice {0}").format(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 a5e1789bb85..b72c0adcc57 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -143,3 +143,14 @@ class TestProformaInvoice(ERPNextTestSuite): sales_order, [(sales_order.items[0].name, 4)], ) + + def test_requires_submitted_sales_order(self): + """The server rejects a proforma against a draft Sales Order (the button is JS-gated only).""" + sales_order = make_sales_order(qty=10, do_not_submit=True) + + self.assertRaises( + frappe.ValidationError, + self.create_proforma, + sales_order, + [(sales_order.items[0].name, 4)], + ) From 0b71c943c1c3880d5faa791cc78d799b8cc0872d Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 23 Jul 2026 19:17:21 +0530 Subject: [PATCH 20/20] fix(selling): don't email cancelled proformas or copy their PDF Address review findings: - send_proforma_email rejects non-issued proformas, and the tab suppresses the action for cancelled rows, so a voided document can't be sent to a customer - mark proforma_pdf as no_copy and disable amendment (a proforma is created only from a Sales Order), so a copied proforma can't carry the original's PDF and number --- erpnext/public/js/sales_order_proforma.js | 8 +++++++- .../doctype/proforma_invoice/proforma_invoice.json | 3 +-- .../selling/doctype/proforma_invoice/proforma_invoice.py | 2 ++ .../doctype/proforma_invoice/test_proforma_invoice.py | 8 ++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js index 5989d32fe0d..5a72e350a41 100644 --- a/erpnext/public/js/sales_order_proforma.js +++ b/erpnext/public/js/sales_order_proforma.js @@ -301,7 +301,13 @@ Object.assign(erpnext.proforma, { { icon: "mail", label: __("Send Email"), - action: (row, refresh) => this.send_email(frm, row.name, refresh), + action: (row, refresh) => { + if (row.status === "Cancelled") { + frappe.msgprint(__("A cancelled Proforma Invoice cannot be emailed.")); + return; + } + this.send_email(frm, row.name, refresh); + }, }, ], }, diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json index 0a45df89088..9fe2b9616af 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json @@ -187,6 +187,7 @@ "fieldname": "proforma_pdf", "fieldtype": "Attach", "label": "Proforma PDF", + "no_copy": 1, "read_only": 1 }, { @@ -250,7 +251,6 @@ "owner": "Administrator", "permissions": [ { - "amend": 1, "cancel": 1, "create": 1, "delete": 1, @@ -264,7 +264,6 @@ "write": 1 }, { - "amend": 1, "cancel": 1, "create": 1, "delete": 1, diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py index cd7f25e5658..2fbf068d882 100644 --- a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -212,6 +212,8 @@ def _proforma_line(so_item, based_on: str, row: dict) -> dict | None: @frappe.whitelist() def send_proforma_email(proforma_name: str, recipients: str) -> None: proforma = frappe.get_doc("Proforma Invoice", proforma_name) + if proforma.docstatus != 1: + frappe.throw(_("Only an issued Proforma Invoice can be emailed.")) if not proforma.proforma_pdf: frappe.throw(_("This Proforma Invoice has no PDF to send.")) diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py index b72c0adcc57..2d9f7843e78 100644 --- a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -9,6 +9,7 @@ from frappe.utils import flt from erpnext.selling.doctype.proforma_invoice.proforma_invoice import ( get_sales_order_items, make_proforma_invoice, + send_proforma_email, ) from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.tests.utils import ERPNextTestSuite @@ -144,6 +145,13 @@ class TestProformaInvoice(ERPNextTestSuite): [(sales_order.items[0].name, 4)], ) + def test_cannot_email_cancelled_proforma(self): + sales_order = make_sales_order(qty=10) + proforma = self.create_proforma(sales_order, [(sales_order.items[0].name, 4)]) + proforma.cancel() + + self.assertRaises(frappe.ValidationError, send_proforma_email, proforma.name, "customer@example.com") + def test_requires_submitted_sales_order(self): """The server rejects a proforma against a draft Sales Order (the button is JS-gated only).""" sales_order = make_sales_order(qty=10, do_not_submit=True)