diff --git a/erpnext/accounts/bulk_payment.py b/erpnext/accounts/bulk_payment.py new file mode 100644 index 00000000000..3a49c36f05e --- /dev/null +++ b/erpnext/accounts/bulk_payment.py @@ -0,0 +1,129 @@ +import frappe +from frappe import _ +from frappe.utils import flt + +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, +): + """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: + 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") + + 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"]) + + 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) + created += 1 + except Exception: + frappe.db.rollback(save_point="bulk_pe") + failed += 1 + frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier)) + + message = _("Created {0} draft Grouped Payment Entries").format(created) + + if failed: + message += " — " + _("{0} skipped (see Error Log)").format(failed) + + frappe.publish_realtime( + "msgprint", + {"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"}, + user=frappe.session.user, + after_commit=True, + ) + + +def _build_grouped_payment_entry(supplier, party_account, names): + pe = get_payment_entry("Purchase Invoice", names[0]) + pe.set("references", []) + + refs = get_outstanding_reference_documents( + { + "party_type": "Supplier", + "party": supplier, + "party_account": party_account, + "company": pe.company, + "vouchers": [frappe._dict(voucher_type="Purchase Invoice", voucher_no=n) for n in names], + } + ) + + for r in refs: + if r.voucher_type != "Purchase Invoice": + continue + pe.append( + "references", + { + "reference_doctype": r.voucher_type, + "reference_name": r.voucher_no, + "bill_no": r.get("bill_no"), + "due_date": r.get("due_date"), + "payment_term": r.get("payment_term"), + "total_amount": r.invoice_amount, + "outstanding_amount": r.outstanding_amount, + "allocated_amount": r.outstanding_amount, + "exchange_rate": r.get("exchange_rate") or 1, + }, + ) + + if not pe.references: + return None + + # received_amount is in paid_to account currency; convert to paid_from account currency for paid_amount + pe.received_amount = sum(r.allocated_amount for r in pe.references) + pe.paid_amount = flt(pe.received_amount * pe.target_exchange_rate, pe.precision("paid_amount")) + pe.set_amounts() + return pe diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index 7016ad371a3..2fa6ceeb08c 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -173,6 +173,10 @@ frappe.query_reports["Accounts Payable"] = { return value; }, + get_datatable_options(options) { + return Object.assign(options, { checkboxColumn: true }); + }, + onload: function (report) { report.page.add_inner_button(__("Accounts Payable Summary"), function () { var filters = report.get_values(); @@ -182,9 +186,145 @@ frappe.query_reports["Accounts Payable"] = { if (frappe.boot.sysdefaults.default_ageing_range) { report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range); } + + if (frappe.model.can_create("Payment Entry")) { + report.page.add_inner_button( + __("Create Payment Entries"), + function () { + erpnext.accounts.create_payment_entries_from_payable_report(report); + }, + __("Actions") + ); + } }, }; +frappe.provide("erpnext.accounts"); +erpnext.accounts.create_payment_entries_from_payable_report = function (report) { + const datatable = report.datatable; + if (!datatable) return; + + const rows = datatable.rowmanager + .getCheckedRows() + .map((i) => datatable.datamanager.data[i]) + .filter((r) => r && r.voucher_type === "Purchase Invoice" && r.voucher_no); + + if (!rows.length) { + frappe.msgprint(__("Select one or more Purchase Invoice rows")); + return; + } + + // build per-(supplier, party_account) summary to match backend grouping key + const supplierMap = {}; + for (const r of rows) { + const key = `${r.party}||${r.party_account}`; + if (!supplierMap[key]) { + supplierMap[key] = { + supplier: r.party, + party_account: r.party_account, + count: 0, + outstanding: 0, + }; + } + supplierMap[key].count += 1; + supplierMap[key].outstanding += r.outstanding || 0; + } + + const overviewFields = [ + { + fieldtype: "Data", + fieldname: "supplier", + label: __("Supplier"), + read_only: 1, + in_list_view: 1, + width: 150, + }, + { + fieldtype: "Data", + fieldname: "party_account", + label: __("Payable Account"), + read_only: 1, + in_list_view: 1, + width: 130, + }, + { + fieldtype: "Int", + fieldname: "invoices", + label: __("Invoices"), + read_only: 1, + in_list_view: 1, + width: 70, + }, + { + fieldtype: "Float", + fieldname: "payable_amount", + label: __("Payable Amount"), + read_only: 1, + in_list_view: 1, + }, + ]; + + 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, + })), + }, + ], + primary_action_label: __("Create"), + secondary_action_label: __("Cancel"), + secondary_action() { + dialog.hide(); + report.datatable.rowmanager.checkAll(false); + }, + 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); + } + + const clearSelection = () => report.datatable.rowmanager.checkAll(false); + + frappe + .call({ + method: "erpnext.accounts.bulk_payment.create_payment_entries", + args: { grouped_invoices, ungrouped_invoices }, + }) + .then(clearSelection) + .catch(clearSelection); + }, + }); + dialog.show(); +}; + erpnext.utils.add_dimensions("Accounts Payable", 10); function get_party_type_options() {