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
This commit is contained in:
Nabin Hait
2026-07-16 16:51:48 +05:30
parent fbdc1f5b1f
commit 07756f2bec
6 changed files with 550 additions and 0 deletions

View File

@@ -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"
}

View File

@@ -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."))

View File

@@ -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": []
}

View File

@@ -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