Merge pull request #57703 from Shllokkk/create-payment-entries-from-payable-report

feat: validate selection and improve Create Payment Entries dialog
This commit is contained in:
Shllokkk
2026-08-02 17:43:17 +05:30
committed by GitHub
2 changed files with 213 additions and 105 deletions

View File

@@ -6,88 +6,148 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_outstanding_reference_documents,
get_payment_entry,
)
from erpnext.utilities.bulk_transaction import transaction_processing
@frappe.whitelist(methods=["POST"])
def create_payment_entries(
grouped_invoices: str | list | None = None,
ungrouped_invoices: str | list | None = None,
):
def create_payment_entries(invoices: str | list | None = None):
"""Create draft Payment Entries from AP report invoice selection."""
frappe.has_permission("Payment Entry", "create", throw=True)
grouped_invoices = [d for d in frappe.parse_json(grouped_invoices or "[]") if d.get("voucher_no")]
ungrouped_invoices = [d for d in frappe.parse_json(ungrouped_invoices or "[]") if d.get("voucher_no")]
if not grouped_invoices and not ungrouped_invoices:
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
if not names:
frappe.throw(_("No Purchase Invoices selected"))
if ungrouped_invoices:
data = [{"name": d["voucher_no"]} for d in ungrouped_invoices]
transaction_processing(data, "Purchase Invoice", "Payment Entry")
payable, excluded = _partition_payable_invoices(names)
if not payable:
frappe.throw(_("None of the selected invoices are payable"))
if grouped_invoices:
groups = {}
for d in grouped_invoices:
key = (d["supplier"], d["party_account"])
groups.setdefault(
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
)["vouchers"].append(d["voucher_no"])
# invoices sharing a (supplier, payable account) are combined into one Payment Entry
groups = {}
for d in payable:
key = (d["supplier"], d["party_account"])
groups.setdefault(
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
)["vouchers"].append(d["voucher_no"])
frappe.msgprint(
_("Started a background job to create {0} Grouped Payment Entries").format(len(groups))
)
frappe.enqueue(
make_grouped_payment_entries,
queue="long",
timeout=1500,
groups=list(groups.values()),
)
def make_grouped_payment_entries(groups):
created, failed = 0, 0
for group in groups:
supplier = group["supplier"]
try:
frappe.db.savepoint("bulk_pe")
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
if not pe:
frappe.db.rollback(save_point="bulk_pe")
failed += 1
frappe.log_error(
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
message=_(
"No outstanding invoices found for the selected vouchers in account {0}"
).format(group["party_account"]),
)
continue
pe.flags.ignore_validate = True
pe.set_title_field()
pe.insert(ignore_mandatory=True)
for group in groups.values():
if _create_payment_entry(group):
created += 1
except Exception:
frappe.db.rollback(save_point="bulk_pe")
else:
failed += 1
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
message = _("Created {0} draft Grouped Payment Entries").format(created)
message = _("Created {0} draft Payment Entries").format(created)
if excluded:
message += "" + _("{0} excluded (not payable)").format(len(excluded))
if failed:
message += "" + _("{0} skipped (see Error Log)").format(failed)
message += "" + _("{0} failed (see Error Log)").format(failed)
frappe.msgprint(message, title=_("Bulk Payment Entries"), indicator="green")
frappe.publish_realtime(
"msgprint",
{"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"},
user=frappe.session.user,
after_commit=True,
@frappe.whitelist()
def get_payable_invoices(invoices: str | list | None = None):
"""Return the live payable subset of the selected invoices for the report dialog."""
frappe.has_permission("Payment Entry", "create", throw=True)
names = [d["voucher_no"] for d in frappe.parse_json(invoices or "[]") if d.get("voucher_no")]
payable, excluded = _partition_payable_invoices(names)
currency = None
if payable:
company = frappe.get_cached_value("Purchase Invoice", payable[0]["voucher_no"], "company")
currency = frappe.get_cached_value("Company", company, "default_currency")
return {"payable": payable, "excluded": excluded, "currency": currency}
def _partition_payable_invoices(names):
"""Split submitted Purchase Invoices into payable ones and excluded ones (with reason).
Returns are debit notes, internal transfers are inter-company, and non-positive
outstanding means already settled — none are valid targets for a supplier payment.
"""
if not names:
return [], []
rows = frappe.get_list(
"Purchase Invoice",
filters={"name": ["in", names], "docstatus": 1},
fields=[
"name",
"supplier",
"credit_to",
"outstanding_amount",
"conversion_rate",
"is_return",
"is_internal_supplier",
],
limit_page_length=0,
)
payable, excluded = [], []
for r in rows:
if r.is_return:
excluded.append({"voucher_no": r.name, "reason": _("Debit Note")})
elif r.is_internal_supplier:
excluded.append({"voucher_no": r.name, "reason": _("Internal Transfer")})
elif flt(r.outstanding_amount) <= 0:
excluded.append({"voucher_no": r.name, "reason": _("Already Paid")})
else:
payable.append(
{
"voucher_no": r.name,
"supplier": r.supplier,
"party_account": r.credit_to,
"outstanding": flt(r.outstanding_amount) * flt(r.conversion_rate or 1),
}
)
# names not returned were cancelled/deleted or no longer readable after the report loaded
found = {r.name for r in rows}
for name in names:
if name not in found:
excluded.append({"voucher_no": name, "reason": _("Not available")})
return payable, excluded
def _create_payment_entry(group):
supplier = group["supplier"]
try:
frappe.db.savepoint("bulk_pe")
if len(group["vouchers"]) == 1:
pe = _build_single_payment_entry(group["vouchers"][0])
else:
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
if not pe:
frappe.db.rollback(save_point="bulk_pe")
frappe.log_error(
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
message=_("No outstanding amount for the selected invoice(s)."),
)
return False
pe.flags.ignore_validate = True
pe.set_title_field()
pe.insert(ignore_mandatory=True)
return True
except Exception:
frappe.db.rollback(save_point="bulk_pe")
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
return False
def _build_single_payment_entry(name):
pe = get_payment_entry("Purchase Invoice", name)
# guard against a stale report row: nothing to allocate means the invoice is already settled
if not pe.references or not any(flt(r.allocated_amount) for r in pe.references):
return None
return pe
def _build_grouped_payment_entry(supplier, party_account, names):
name_set = set(names)
pe = get_payment_entry("Purchase Invoice", names[0])
pe.set("references", [])
@@ -101,8 +161,9 @@ def _build_grouped_payment_entry(supplier, party_account, names):
}
)
# get_negative_outstanding_invoices ignores the vouchers filter, so bound refs to the selection
for r in refs:
if r.voucher_type != "Purchase Invoice":
if r.voucher_type != "Purchase Invoice" or r.voucher_no not in name_set:
continue
pe.append(
"references",

View File

@@ -234,20 +234,36 @@ function create_payment_entries_from_payable_report(report) {
return;
}
// build per-(supplier, party_account) summary to match backend grouping key
// validate against live state: only unpaid/partly-paid invoices with real outstanding are payable
frappe.call({
method: "erpnext.accounts.bulk_payment.get_payable_invoices",
args: { invoices: rows.map((r) => ({ voucher_no: r.voucher_no })) },
callback: ({ message }) => {
const { payable = [], excluded = [], currency } = message || {};
if (!payable.length) {
frappe.msgprint(__("None of the selected invoices are payable"));
return;
}
show_create_payment_entries_dialog(report, payable, excluded, currency);
},
});
}
function show_create_payment_entries_dialog(report, payable, excluded, currency) {
// group by (supplier, party_account) for the overview — matches the backend grouping key
const supplierMap = {};
for (const r of rows) {
const key = `${r.party}||${r.party_account}`;
for (const inv of payable) {
const key = `${inv.supplier}||${inv.party_account}`;
if (!supplierMap[key]) {
supplierMap[key] = {
supplier: r.party,
party_account: r.party_account,
supplier: inv.supplier,
party_account: inv.party_account,
count: 0,
outstanding: 0,
};
}
supplierMap[key].count += 1;
supplierMap[key].outstanding += r.outstanding || 0;
supplierMap[key].outstanding += inv.outstanding || 0;
}
const overviewFields = [
@@ -284,24 +300,36 @@ function create_payment_entries_from_payable_report(report) {
},
];
const fields = [];
if (excluded.length) {
fields.push({ fieldtype: "HTML", fieldname: "excluded_note", options: excluded_note_html(excluded) });
}
fields.push({
fieldname: "supplier_overview",
fieldtype: "Table",
label: __("Supplier Overview"),
cannot_add_rows: true,
cannot_delete_rows: true,
fields: overviewFields,
data: Object.values(supplierMap).map((d) => ({
supplier: d.supplier,
party_account: d.party_account,
invoices: d.count,
payable_amount: d.outstanding,
})),
});
const pe_count = Object.keys(supplierMap).length;
const grand_total = Object.values(supplierMap).reduce((sum, d) => sum + d.outstanding, 0);
fields.push({
fieldtype: "HTML",
fieldname: "summary_footer",
options: summary_footer_html(pe_count, grand_total, currency),
});
const dialog = new frappe.ui.Dialog({
title: __("Create Payment Entries"),
fields: [
{
fieldname: "supplier_overview",
fieldtype: "Table",
label: __("Supplier Overview"),
cannot_add_rows: true,
cannot_delete_rows: true,
fields: overviewFields,
data: Object.values(supplierMap).map((d) => ({
supplier: d.supplier,
party_account: d.party_account,
invoices: d.count,
payable_amount: d.outstanding,
})),
},
],
fields: fields,
primary_action_label: __("Create"),
secondary_action_label: __("Cancel"),
secondary_action() {
@@ -311,32 +339,15 @@ function create_payment_entries_from_payable_report(report) {
primary_action() {
dialog.hide();
const groupedKeys = new Set(
Object.values(supplierMap)
.filter((d) => d.count > 1)
.map((d) => `${d.supplier}||${d.party_account}`)
);
const grouped_invoices = [];
const ungrouped_invoices = [];
for (const r of rows) {
const payload = {
voucher_no: r.voucher_no,
supplier: r.party,
party_account: r.party_account,
};
(groupedKeys.has(`${r.party}||${r.party_account}`)
? grouped_invoices
: ungrouped_invoices
).push(payload);
}
// backend re-derives supplier/party_account and grouping from live data
const invoices = payable.map((inv) => ({ voucher_no: inv.voucher_no }));
const clearSelection = () => report.datatable.rowmanager.checkAll(false);
frappe
.call({
method: "erpnext.accounts.bulk_payment.create_payment_entries",
args: { grouped_invoices, ungrouped_invoices },
args: { invoices },
})
.then(clearSelection)
.catch(clearSelection);
@@ -345,6 +356,42 @@ function create_payment_entries_from_payable_report(report) {
dialog.show();
}
function summary_footer_html(pe_count, grand_total, currency) {
return `<div style="
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--margin-sm);
font-size: var(--text-sm);
">
<span class="text-muted">${__("Payment Entries are created as drafts for your review")}</span>
<span>${__("{0} Payment Entries", [pe_count])} ·
<strong>${format_currency(grand_total, currency)}</strong></span>
</div>`;
}
function excluded_note_html(excluded) {
const counts = {};
for (const e of excluded) {
counts[e.reason] = (counts[e.reason] || 0) + 1;
}
const summary = Object.entries(counts)
.map(([reason, n]) => `${n} ${reason}`)
.join(", ");
return `<div style="
background-color: var(--bg-yellow);
color: var(--text-on-yellow);
font-size: var(--text-sm);
border-radius: var(--border-radius);
padding: var(--padding-sm) var(--padding-md);
margin-bottom: var(--margin-sm);
">
<span style="font-weight: var(--weight-medium);">${__("{0} invoice(s) excluded", [
excluded.length,
])}</span>: ${frappe.utils.escape_html(summary)}
</div>`;
}
erpnext.utils.add_dimensions("Accounts Payable", 10);
function get_party_type_options() {