mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-24 22:07:05 +00:00
Compare commits
8 Commits
develop
...
proforma-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
defff46d8f | ||
|
|
7dd3598156 | ||
|
|
6bf5fa51d5 | ||
|
|
d29ccfb569 | ||
|
|
c1808c9124 | ||
|
|
fa9d7a75e1 | ||
|
|
02854a48ee | ||
|
|
b03e48f453 |
@@ -458,6 +458,12 @@ def validate_child_on_delete(row, parent, ordered_item=None) -> None:
|
||||
"Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
|
||||
).format(row.idx, row.item_code)
|
||||
)
|
||||
if frappe.db.exists("Proforma Invoice Item", {"so_detail": row.name, "docstatus": 1}):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Cannot delete item {1} which has an issued Proforma Invoice.").format(
|
||||
row.idx, row.item_code
|
||||
)
|
||||
)
|
||||
|
||||
if parent.doctype == "Purchase Order" and flt(row.received_qty):
|
||||
frappe.throw(
|
||||
|
||||
@@ -4,19 +4,21 @@
|
||||
frappe.ui.form.on("Sales Order", {
|
||||
refresh(frm) {
|
||||
erpnext.proforma.toggle_tab(frm, false);
|
||||
if (frm.doc.docstatus !== 1) return;
|
||||
if (frm.doc.docstatus === 0) 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);
|
||||
if (frm.doc.docstatus === 1) {
|
||||
// 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);
|
||||
});
|
||||
},
|
||||
@@ -117,6 +119,12 @@ Object.assign(erpnext.proforma, {
|
||||
read_only: 1,
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "description",
|
||||
fieldtype: "Text Editor",
|
||||
label: __("Description"),
|
||||
in_list_view: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "qty",
|
||||
fieldtype: "Float",
|
||||
@@ -205,11 +213,12 @@ Object.assign(erpnext.proforma, {
|
||||
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 }
|
||||
);
|
||||
.map((row) => ({
|
||||
so_detail: row.so_detail,
|
||||
description: row.description,
|
||||
qty: row.qty,
|
||||
amount: row.amount,
|
||||
}));
|
||||
|
||||
if (!items.length) {
|
||||
frappe.msgprint(__("Please enter a quantity or amount for at least one item."));
|
||||
@@ -314,6 +323,7 @@ Object.assign(erpnext.proforma, {
|
||||
],
|
||||
});
|
||||
list.refresh();
|
||||
if (frm.doc.docstatus !== 1) return;
|
||||
|
||||
frappe.ui
|
||||
.button({
|
||||
|
||||
@@ -1,8 +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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
frappe.ui.form.on("Proforma Invoice", {
|
||||
refresh(frm) {
|
||||
frm.page.btn_primary.toggle(frm.doc.docstatus !== 2);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,8 +45,17 @@ class ProformaInvoice(Document):
|
||||
|
||||
def validate(self) -> None:
|
||||
validate_feature_enabled()
|
||||
self.validate_amended_doc()
|
||||
self.set_total_qty()
|
||||
|
||||
def validate_amended_doc(self) -> None:
|
||||
if self.amended_from:
|
||||
frappe.throw(
|
||||
_("Cannot amend {0} {1}, please create a new one instead.").format(
|
||||
self.doctype, frappe.bold(self.amended_from)
|
||||
)
|
||||
)
|
||||
|
||||
def before_submit(self) -> None:
|
||||
self.status = "Issued"
|
||||
|
||||
@@ -80,6 +89,7 @@ class ProformaInvoice(Document):
|
||||
for item in sales_order.items:
|
||||
item.qty = lines[item.name].qty
|
||||
item.rate = lines[item.name].rate
|
||||
item.description = lines[item.name].description
|
||||
item.discount_amount = 0
|
||||
item.discount_percentage = 0
|
||||
sales_order.run_method("calculate_taxes_and_totals")
|
||||
@@ -116,6 +126,7 @@ def get_sales_order_items(sales_order: str) -> list[dict]:
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"item_name": item.item_name,
|
||||
"description": item.description,
|
||||
"uom": item.uom,
|
||||
"so_detail": item.name,
|
||||
"qty": flt(item.qty),
|
||||
@@ -213,6 +224,7 @@ def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
|
||||
return {
|
||||
"item_code": so_item.item_code,
|
||||
"item_name": so_item.item_name,
|
||||
"description": row.get("description") or so_item.description,
|
||||
"uom": so_item.uom,
|
||||
"qty": qty,
|
||||
"rate": rate,
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.services.child_item_update import update_child_qty_rate
|
||||
from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
|
||||
get_sales_order_items,
|
||||
make_proforma_invoice,
|
||||
@@ -172,6 +173,48 @@ class TestProformaInvoice(ERPNextTestSuite):
|
||||
("Proforma Invoice PRO-TEST-0001", "Please find attached the proforma invoice PRO-TEST-0001."),
|
||||
)
|
||||
|
||||
def test_line_description_is_editable(self):
|
||||
sales_order = make_sales_order(qty=10, do_not_submit=True)
|
||||
sales_order.items[0].description = "Ordered description"
|
||||
sales_order.submit()
|
||||
so_detail = sales_order.items[0].name
|
||||
|
||||
edited = make_proforma_invoice(
|
||||
sales_order.name, json.dumps([{"so_detail": so_detail, "qty": 4, "description": "Edited"}])
|
||||
)
|
||||
unedited = self.create_proforma(sales_order, [(so_detail, 4)])
|
||||
|
||||
self.assertEqual(get_sales_order_items(sales_order.name)[0]["description"], "Ordered description")
|
||||
self.assertEqual(frappe.get_doc("Proforma Invoice", edited).items[0].description, "Edited")
|
||||
self.assertEqual(unedited.items[0].description, "Ordered description")
|
||||
|
||||
def test_update_items_cannot_delete_a_proformed_row(self):
|
||||
sales_order = make_sales_order(
|
||||
item_list=[
|
||||
{"item_code": "_Test Item", "qty": 5, "rate": 100},
|
||||
{"item_code": "_Test Item 2", "qty": 2, "rate": 50},
|
||||
]
|
||||
)
|
||||
proformed, other = sales_order.items
|
||||
proforma = self.create_proforma(sales_order, [(proformed.name, 2)])
|
||||
keep_other = json.dumps(
|
||||
[{"item_code": other.item_code, "qty": other.qty, "rate": other.rate, "docname": other.name}]
|
||||
)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, update_child_qty_rate, "Sales Order", keep_other, sales_order.name
|
||||
)
|
||||
|
||||
proforma.cancel()
|
||||
update_child_qty_rate("Sales Order", keep_other, sales_order.name)
|
||||
sales_order.reload()
|
||||
self.assertEqual([item.name for item in sales_order.items], [other.name])
|
||||
|
||||
def test_amended_proforma_is_rejected(self):
|
||||
proforma = frappe.get_doc({"doctype": "Proforma Invoice", "amended_from": "PRO-TEST-0001"})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, proforma.validate_amended_doc)
|
||||
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"item_name",
|
||||
"description",
|
||||
"column_break_qty",
|
||||
"qty",
|
||||
"uom",
|
||||
@@ -32,6 +33,11 @@
|
||||
"label": "Item Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_qty",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -79,7 +85,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-16 00:00:00.000000",
|
||||
"modified": "2026-09-24 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Proforma Invoice Item",
|
||||
|
||||
@@ -14,6 +14,7 @@ class ProformaInvoiceItem(Document):
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Currency
|
||||
description: DF.TextEditor | None
|
||||
item_code: DF.Link
|
||||
item_name: DF.Data | None
|
||||
parent: DF.Data
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Print Format",
|
||||
"font_size": 0,
|
||||
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t\t.proforma-print .footer-note { margin-top: 30px; font-size: 11px; color: #6b7280; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Against Sales Order\") }}</td><td class=\"text-right\">{{ doc.name }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td><strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{% if doc.customer_address %}{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td><strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n\n\t<div class=\"footer-note\">\n\t\t{{ _(\"This is a proforma invoice and is not a demand for payment or a tax invoice.\") }}\n\t</div>\n</div>\n",
|
||||
"html": "<div class=\"proforma-print\">\n\t<style>\n\t\t.proforma-print { font-family: \"Inter\", sans-serif; color: #1f272e; font-size: 12px; }\n\t\t.proforma-print h2 { margin: 0; font-size: 20px; letter-spacing: 1px; }\n\t\t.proforma-print .muted { color: #6b7280; }\n\t\t.proforma-print table { width: 100%; border-collapse: collapse; }\n\t\t.proforma-print .meta-table td { padding: 2px 0; vertical-align: top; }\n\t\t.proforma-print .items-table th, .proforma-print .items-table td {\n\t\t\tborder-bottom: 1px solid #e5e7eb; padding: 8px 6px; text-align: left;\n\t\t}\n\t\t.proforma-print .items-table th { border-bottom: 2px solid #9ca3af; }\n\t\t.proforma-print .text-right { text-align: right !important; }\n\t\t.proforma-print .totals { width: 45%; margin-left: auto; margin-top: 12px; }\n\t\t.proforma-print .totals td { padding: 4px 6px; }\n\t\t.proforma-print .grand { border-top: 2px solid #9ca3af; font-weight: 600; font-size: 14px; }\n\t</style>\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td style=\"width: 60%;\">\n\t\t\t\t<h2>{{ _(\"PROFORMA INVOICE\") }}</h2>\n\t\t\t\t<div class=\"muted\">{{ doc.company }}</div>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t<table style=\"width: 100%;\">\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Proforma No\") }}</td><td class=\"text-right\">{{ doc.proforma_no or doc.name }}</td></tr>\n\t\t\t\t\t<tr><td class=\"text-right muted\">{{ _(\"Date\") }}</td><td class=\"text-right\">{{ frappe.utils.formatdate(doc.proforma_date) }}</td></tr>\n\t\t\t\t</table>\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<hr style=\"border: none; border-top: 1px solid #e5e7eb; margin: 14px 0;\">\n\n\t<table class=\"meta-table\">\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t<strong>{{ _(\"Bill To\") }}</strong><br>{{ doc.customer_name }}\n\t\t\t\t{% if doc.customer_address %}<br>{{ doc.get_formatted(\"address_display\") }}{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</table>\n\n\t<table class=\"items-table\" style=\"margin-top: 16px;\">\n\t\t<thead>\n\t\t\t<tr>\n\t\t\t\t<th style=\"width: 5%;\">{{ _(\"Sr\") }}</th>\n\t\t\t\t<th style=\"width: 45%;\">{{ _(\"Item\") }}</th>\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 14%;\">{{ _(\"Qty\") }}</th>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<th class=\"text-right\" style=\"width: 16%;\">{{ _(\"Rate\") }}</th>{% endif %}\n\t\t\t\t<th class=\"text-right\" style=\"width: 20%;\">{{ _(\"Amount\") }}</th>\n\t\t\t</tr>\n\t\t</thead>\n\t\t<tbody>\n\t\t\t{% for row in doc.items %}\n\t\t\t<tr>\n\t\t\t\t<td>{{ loop.index }}</td>\n\t\t\t\t<td>\n\t\t\t\t\t<strong>{{ row.item_code }}</strong>{% if row.item_name != row.item_code %}<br><span class=\"muted\">{{ row.item_name }}</span>{% endif %}\n\t\t\t\t\t{% if row.description and frappe.utils.strip_html(row.description).strip() != row.item_name %}<div class=\"muted\">{{ row.description }}</div>{% endif %}\n\t\t\t\t</td>\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"qty\") }} {{ row.uom }}</td>{% endif %}\n\t\t\t\t{% if not doc.hide_item_qty %}<td class=\"text-right\">{{ row.get_formatted(\"rate\", doc) }}</td>{% endif %}\n\t\t\t\t<td class=\"text-right\">{{ row.get_formatted(\"amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endfor %}\n\t\t</tbody>\n\t</table>\n\n\t<table class=\"totals\">\n\t\t<tr>\n\t\t\t<td class=\"muted\">{{ _(\"Net Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"net_total\") }}</td>\n\t\t</tr>\n\t\t{% for tax in doc.taxes %}\n\t\t\t{% if tax.tax_amount %}\n\t\t\t<tr>\n\t\t\t\t<td class=\"muted\">{{ tax.description }}</td>\n\t\t\t\t<td class=\"text-right\">{{ tax.get_formatted(\"tax_amount\", doc) }}</td>\n\t\t\t</tr>\n\t\t\t{% endif %}\n\t\t{% endfor %}\n\t\t<tr class=\"grand\">\n\t\t\t<td>{{ _(\"Grand Total\") }}</td>\n\t\t\t<td class=\"text-right\">{{ doc.get_formatted(\"grand_total\") }}</td>\n\t\t</tr>\n\t</table>\n</div>\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": "2026-09-24 12:30:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Proforma Invoice",
|
||||
|
||||
Reference in New Issue
Block a user