diff --git a/erpnext/accounts/bulk_payment.py b/erpnext/accounts/bulk_payment.py index 3a49c36f05e..37ccafba44d 100644 --- a/erpnext/accounts/bulk_payment.py +++ b/erpnext/accounts/bulk_payment.py @@ -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", diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index f148f3fa585..b0823a7c702 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -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 `
+ ${__("Payment Entries are created as drafts for your review")} + ${__("{0} Payment Entries", [pe_count])} · + ${format_currency(grand_total, currency)} +
`; +} + +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 `
+ ${__("{0} invoice(s) excluded", [ + excluded.length, + ])}: ${frappe.utils.escape_html(summary)} +
`; +} + erpnext.utils.add_dimensions("Accounts Payable", 10); function get_party_type_options() {