mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 22:51:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user