mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-17 16:38:41 +00:00
Merge pull request #57263 from nabinhait/proforma-invoice
feat(selling): Proforma Invoice against Sales Order
This commit is contained in:
@@ -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",
|
||||
|
||||
358
erpnext/public/js/sales_order_proforma.js
Normal file
358
erpnext/public/js/sales_order_proforma.js
Normal file
@@ -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(
|
||||
`<div class="text-danger small" style="margin-top: 8px;">${__(
|
||||
"Total proforma {0} (including past proformas) exceeds the ordered {0} for: {1}",
|
||||
[basis, frappe.utils.escape_html(exceeded.join(", "))]
|
||||
)}</div>`
|
||||
);
|
||||
},
|
||||
|
||||
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: $("<div></div>").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(
|
||||
$('<div class="flex justify-end" style="margin: 10px 0 20px;"></div>').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")
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
285
erpnext/selling/doctype/proforma_invoice/proforma_invoice.json
Normal file
285
erpnext/selling/doctype/proforma_invoice/proforma_invoice.json
Normal file
@@ -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"
|
||||
}
|
||||
235
erpnext/selling/doctype/proforma_invoice/proforma_invoice.py
Normal file
235
erpnext/selling/doctype/proforma_invoice/proforma_invoice.py
Normal file
@@ -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."))
|
||||
@@ -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)],
|
||||
)
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]},
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<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",
|
||||
"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"
|
||||
}
|
||||
Reference in New Issue
Block a user