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
This commit is contained in:
Nabin Hait
2026-07-17 11:56:20 +05:30
parent f9a09e1b3d
commit 473c655cb2
6 changed files with 134 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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