diff --git a/erpnext/hooks.py b/erpnext/hooks.py index d19be15485c..caa86c3225b 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..5a72e350a41 --- /dev/null +++ b/erpnext/public/js/sales_order_proforma.js @@ -0,0 +1,358 @@ +// 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; + + // 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); + }); + }, +}); + +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_sales_order_items", + args: { sales_order: frm.doc.name }, + callback: (r) => this.show_dialog(frm, r.message || []), + }); + }, + + 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, so_items, series ? 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", + 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: "based_on", + fieldtype: "Select", + label: __("Based On"), + options: ["Quantity", "Amount"], + 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", + cannot_add_rows: true, + // 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", + fieldtype: "Data", + label: __("Item"), + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "qty", + fieldtype: "Float", + label: __("Qty"), + in_list_view: 1, + onchange: function () { + // 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); + }, + }, + { + fieldname: "amount", + fieldtype: "Currency", + 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); + }, + + // 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", true); + 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) { + const by_amount = values.based_on === "Amount"; + const items = (values.items || []) + .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.")); + return; + } + + frappe.call({ + method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.make_proforma_invoice", + args: { + 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, + }, + 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", + }); + // Open the Proforma tab once the reloaded form has rendered the list. + frm._activate_proforma_tab = true; + 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 container = frm.get_field("proforma_html").$wrapper.empty(); + const list = new frappe.ui.EmbeddedList({ + 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]] }, + 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() { + 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: [ + { + 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 === "Cancelled" ? "red" : "green"), + }, + { + 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) => { + if (row.status === "Cancelled") { + frappe.msgprint(__("A cancelled Proforma Invoice cannot be emailed.")); + return; + } + this.send_email(frm, row.name, refresh); + }, + }, + ], + }, + ], + }); + list.refresh(); + + frappe.ui + .button({ + label: __("New Proforma Invoice"), + icon: "plus", + variant: "subtle", + size: "sm", + onclick: () => this.open_dialog(frm), + }) + .appendTo( + $('
').appendTo(container) + ); + }, + + 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/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.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 new file mode 100644 index 00000000000..9fe2b9616af --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json @@ -0,0 +1,285 @@ +{ + "actions": [], + "autoname": "naming_series:", + "creation": "2026-07-16 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "details_section", + "naming_series", + "sales_order", + "customer", + "customer_name", + "company", + "column_break_header", + "proforma_date", + "currency", + "based_on", + "hide_item_qty", + "items_section", + "items", + "totals_section", + "column_break_totals", + "total_qty", + "column_break_fukr", + "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", + "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 + }, + { + "default": "Quantity", + "fieldname": "based_on", + "fieldtype": "Select", + "label": "Based On", + "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", + "label": "Items" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Proforma Invoice Item", + "reqd": 1 + }, + { + "fieldname": "totals_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_totals", + "fieldtype": "Column Break" + }, + { + "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": "column_break_print", + "fieldtype": "Column Break" + }, + { + "fieldname": "proforma_pdf", + "fieldtype": "Attach", + "label": "Proforma PDF", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "status_section", + "fieldtype": "Section Break", + "label": "Status" + }, + { + "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": "column_break_status", + "fieldtype": "Column Break" + }, + { + "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 + }, + { + "fieldname": "column_break_fukr", + "fieldtype": "Column Break" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2026-07-19 11:15:50.347119", + "modified_by": "Administrator", + "module": "Selling", + "name": "Proforma Invoice", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales User", + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "submit": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "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..2fbf068d882 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py @@ -0,0 +1,235 @@ +# 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 + based_on: DF.Literal["Quantity", "Amount"] + 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 + hide_item_qty: DF.Check + 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() + + def before_submit(self) -> None: + self.status = "Issued" + + def on_submit(self) -> None: + self.generate_and_attach_pdf() + + def on_cancel(self) -> None: + self.db_set("status", "Cancelled") + + def set_total_qty(self) -> None: + self.total_qty = sum(flt(item.qty) for item in self.items) + + 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, 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 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) + 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 = 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 + sales_order.hide_item_qty = self.hide_item_qty + 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_sales_order_items(sales_order: str) -> list[dict]: + """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, + "item_name": item.item_name, + "uom": item.uom, + "so_detail": item.name, + "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, + 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, +) -> str: + """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" (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) + 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") + 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( + "Selling Settings", "default_proforma_print_format" + ) + proforma.letter_head = letter_head + + for row in selected: + so_item = so_items.get(row.get("so_detail")) + if not so_item: + continue + line = _proforma_line(so_item, based_on, row) + if line: + proforma.append("items", line) + + if not proforma.items: + 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": + # 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 + 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) + 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.")) + + 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), + 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/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py new file mode 100644 index 00000000000..2d9f7843e78 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py @@ -0,0 +1,164 @@ +# 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_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 + + +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 must not touch delivery/billing or the source Sales Order.""" + 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) + # ordered qty untouched (in-memory SO copy never persisted) + self.assertEqual(flt(item.qty), 10) + + 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_amount_based_proforma(self): + """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, "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), 5) + self.assertEqual(flt(item.rate), 50) # 250 / 5 + 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_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_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) + + self.assertRaises( + frappe.ValidationError, + self.create_proforma, + sales_order, + [(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) + + self.assertRaises( + frappe.ValidationError, + self.create_proforma, + sales_order, + [(sales_order.items[0].name, 4)], + ) 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..d3ba6403a18 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json @@ -0,0 +1,91 @@ +{ + "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", + "rate", + "amount", + "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 + }, + { + "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", + "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..86a326aa774 --- /dev/null +++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py @@ -0,0 +1,28 @@ +# 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 + + 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 + + pass 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"]}, 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 ebae841dde9..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", @@ -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 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..8ef8184928d --- /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{% 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, + "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" +}