From 07756f2bec3992d16fa5c9d6aed60cbd3577e6bd Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 16 Jul 2026 16:51:48 +0530 Subject: [PATCH] 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