Merge pull request #55779 from nabinhait/refactor-je-functions

refactor(journal_entry): smaller functions, Query Builder, type hints and docstrings
This commit is contained in:
Nabin Hait
2026-06-10 11:20:05 +05:30
committed by GitHub
3 changed files with 508 additions and 376 deletions

View File

@@ -409,18 +409,16 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
} }
get_outstanding(doctype, docname, company, child) { get_outstanding(doctype, docname, company, child) {
var args = {
doctype: doctype,
docname: docname,
party: child.party,
account: child.account,
account_currency: child.account_currency,
company: company,
};
return frappe.call({ return frappe.call({
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding", method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
args: { args: args }, args: {
doctype: doctype,
docname: docname,
company: company,
account: child.account,
party: child.party,
account_currency: child.account_currency,
},
callback: function (r) { callback: function (r) {
if (r.message) { if (r.message) {
$.each(r.message, function (field, value) { $.each(r.message, function (field, value) {

View File

@@ -8,6 +8,7 @@ import frappe
from frappe import _, msgprint, scrub from frappe import _, msgprint, scrub
from frappe.core.doctype.submission_queue.submission_queue import queue_submission from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
import erpnext import erpnext
@@ -43,6 +44,14 @@ class StockAccountInvalidTransaction(frappe.ValidationError):
class JournalEntry(AccountsController): class JournalEntry(AccountsController):
"""Double-entry accounting voucher for manual and system-generated postings.
Besides plain journal entries it also backs depreciation, asset disposal,
exchange gain/loss, deferred revenue/expense, inter-company and periodic
accounting entries: it validates the account rows (party, references,
currency) and posts the corresponding GL entries on submit.
"""
# begin: auto-generated types # begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block. # This code is auto-generated. Do not modify anything in this block.
@@ -128,6 +137,7 @@ class JournalEntry(AccountsController):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def validate(self): def validate(self):
"""Validate the account rows (party, references, currency, stock) and build derived fields."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
from erpnext.accounts.doctype.journal_entry.services.reference_validator import ( from erpnext.accounts.doctype.journal_entry.services.reference_validator import (
JournalEntryReferenceValidator, JournalEntryReferenceValidator,
@@ -188,28 +198,33 @@ class JournalEntry(AccountsController):
validate_docs_for_deferred_accounting([self.name], []) validate_docs_for_deferred_accounting([self.name], [])
def submit(self): def submit(self):
"""Submit inline, or queue submission in the background for large entries."""
if len(self.accounts) > 100 and not self.meta.queue_in_background: if len(self.accounts) > 100 and not self.meta.queue_in_background:
queue_submission(self, "_submit") queue_submission(self, "_submit")
else: else:
return self._submit() return self._submit()
def before_cancel(self): def before_cancel(self):
"""Block cancellation when a submitted Asset Value Adjustment is linked to this entry."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
AssetService(self).has_asset_adjustment_entry() AssetService(self).has_asset_adjustment_entry()
def cancel(self): def cancel(self):
"""Cancel inline, or queue cancellation in the background for large entries."""
if len(self.accounts) > 100: if len(self.accounts) > 100:
queue_submission(self, "_cancel") queue_submission(self, "_cancel")
else: else:
return self._cancel() return self._cancel()
def before_submit(self): def before_submit(self):
"""Ensure total debit equals total credit before submission (skipped on data import)."""
# Do not validate while importing via data import # Do not validate while importing via data import
if not frappe.flags.in_import: if not frappe.flags.in_import:
self.validate_total_debit_and_credit() self.validate_total_debit_and_credit()
def on_submit(self): def on_submit(self):
"""Post GL entries and propagate the submission to assets, inter-company JE and invoice discounting."""
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
self.validate_cheque_info() self.validate_cheque_info()
@@ -221,18 +236,16 @@ class JournalEntry(AccountsController):
JournalTaxWithholding(self).on_submit() JournalTaxWithholding(self).on_submit()
@frappe.whitelist() @frappe.whitelist()
def get_balance_for_periodic_accounting(self): def get_balance_for_periodic_accounting(self) -> None:
"""Rebuild the entry rows from the stock-vs-ledger difference of each stock account."""
self.validate_company_for_periodic_accounting() self.validate_company_for_periodic_accounting()
stock_accounts = self.get_stock_accounts_for_periodic_accounting()
self.set("accounts", []) self.set("accounts", [])
for account in stock_accounts: for account in self.get_stock_accounts_for_periodic_accounting():
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance( account_bal, stock_bal, _warehouse_list = get_stock_and_account_balance(
account, self.posting_date, self.company account, self.posting_date, self.company
) )
difference_value = flt(stock_bal - account_bal, self.precision("difference")) difference_value = flt(stock_bal - account_bal, self.precision("difference"))
if difference_value == 0: if difference_value == 0:
frappe.msgprint( frappe.msgprint(
_("No difference found for stock account {0}").format(frappe.bold(account)), _("No difference found for stock account {0}").format(frappe.bold(account)),
@@ -240,23 +253,26 @@ class JournalEntry(AccountsController):
) )
continue continue
self.append( self._append_periodic_difference_rows(account, difference_value)
"accounts",
{
"account": account,
"debit_in_account_currency": difference_value if difference_value > 0 else 0,
"credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self.append( def _append_periodic_difference_rows(self, account: str, difference_value: float) -> None:
"accounts", """Append the stock account row and its offsetting difference-account row."""
{ self.append(
"account": self.periodic_entry_difference_account, "accounts",
"credit_in_account_currency": difference_value if difference_value > 0 else 0, {
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0, "account": account,
}, "debit_in_account_currency": difference_value if difference_value > 0 else 0,
) "credit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
self.append(
"accounts",
{
"account": self.periodic_entry_difference_account,
"credit_in_account_currency": difference_value if difference_value > 0 else 0,
"debit_in_account_currency": abs(difference_value) if difference_value < 0 else 0,
},
)
def validate_company_for_periodic_accounting(self): def validate_company_for_periodic_accounting(self):
if erpnext.is_perpetual_inventory_enabled(self.company): if erpnext.is_perpetual_inventory_enabled(self.company):
@@ -302,6 +318,7 @@ class JournalEntry(AccountsController):
self.repost_accounting_entries() self.repost_accounting_entries()
def on_cancel(self): def on_cancel(self):
"""Reverse GL entries and unlink asset, inter-company and advance references on cancel."""
# Cancel tax withholding entries # Cancel tax withholding entries
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
@@ -385,49 +402,44 @@ class JournalEntry(AccountsController):
self.name, self.name,
) )
def update_invoice_discounting(self): def update_invoice_discounting(self) -> None:
def _validate_invoice_discounting_status(inv_disc, id_status, expected_status, row_id): """Advance each linked Invoice Discounting to its next status on submit/cancel."""
id_link = get_link_to_form("Invoice Discounting", inv_disc) discounting_names = {
if id_status != expected_status: row.reference_name for row in self.accounts if row.reference_type == "Invoice Discounting"
frappe.throw( }
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format( for name in discounting_names:
d.idx, expected_status, id_link inv_disc = frappe.get_doc("Invoice Discounting", name)
) if status := self._get_next_invoice_discounting_status(inv_disc):
) inv_disc.set_status(status=status)
invoice_discounting_list = list( def _get_next_invoice_discounting_status(self, inv_disc) -> str | None:
set([d.reference_name for d in self.accounts if d.reference_type == "Invoice Discounting"]) """Validate the current status and return the next one from the loan account row."""
) for row in self.accounts:
for inv_disc in invoice_discounting_list: if row.account != inv_disc.short_term_loan or row.reference_name != inv_disc.name:
inv_disc_doc = frappe.get_doc("Invoice Discounting", inv_disc) continue
status = None
for d in self.accounts: submitting = self.docstatus == 1
if d.account == inv_disc_doc.short_term_loan and d.reference_name == inv_disc: if row.credit > 0:
if self.docstatus == 1: expected, next_status = (
if d.credit > 0: ("Sanctioned", "Disbursed") if submitting else ("Disbursed", "Sanctioned")
_validate_invoice_discounting_status( )
inv_disc, inv_disc_doc.status, "Sanctioned", d.idx elif row.debit > 0:
) expected, next_status = ("Disbursed", "Settled") if submitting else ("Settled", "Disbursed")
status = "Disbursed" else:
elif d.debit > 0: return None
_validate_invoice_discounting_status(
inv_disc, inv_disc_doc.status, "Disbursed", d.idx self._validate_invoice_discounting_status(inv_disc, expected, row.idx)
) return next_status
status = "Settled" return None
else:
if d.credit > 0: def _validate_invoice_discounting_status(self, inv_disc, expected_status: str, row_idx: int) -> None:
_validate_invoice_discounting_status( """Throw unless the Invoice Discounting is in the status expected for this transition."""
inv_disc, inv_disc_doc.status, "Disbursed", d.idx if inv_disc.status != expected_status:
) frappe.throw(
status = "Sanctioned" _("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
elif d.debit > 0: row_idx, expected_status, get_link_to_form("Invoice Discounting", inv_disc.name)
_validate_invoice_discounting_status( )
inv_disc, inv_disc_doc.status, "Settled", d.idx )
)
status = "Disbursed"
break
if status:
inv_disc_doc.set_status(status=status)
def unlink_advance_entry_reference(self): def unlink_advance_entry_reference(self):
for d in self.get("accounts"): for d in self.get("accounts"):
@@ -543,62 +555,76 @@ class JournalEntry(AccountsController):
self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency and self.is_system_generated self.voucher_type == "Exchange Gain Or Loss" and self.multi_currency and self.is_system_generated
) )
def validate_against_jv(self): def validate_against_jv(self) -> None:
for d in self.get("accounts"): """Validate every account row that references another Journal Entry."""
if d.reference_type == "Journal Entry": for row in self.get("accounts"):
account_root_type = frappe.get_cached_value("Account", d.account, "root_type") if row.reference_type == "Journal Entry":
if ( self._validate_jv_reference(row)
account_root_type == "Asset"
and flt(d.debit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(d.idx, d.account)
)
elif (
account_root_type == "Liability"
and flt(d.credit) > 0
and not self.system_generated_gain_loss()
):
frappe.throw(
_(
"Row #{0}: For {1}, you can select reference document only if account gets debited"
).format(d.idx, d.account)
)
if d.reference_name == self.name: def _validate_jv_reference(self, row) -> None:
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column")) """Validate a single 'Against Journal Entry' row: direction, no self-reference,
and the presence of an unmatched entry on the referenced Journal Entry."""
self._validate_jv_reference_direction(row)
against_entries = frappe.db.sql( if row.reference_name == self.name:
"""select * from `tabJournal Entry Account` frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
where account = %s and docstatus = 1 and parent = %s
and (reference_type is null or reference_type in ('', 'Sales Order', 'Purchase Order')) against_entries = self._get_against_jv_entries(row)
""", if not against_entries:
(d.account, d.reference_name), if self.voucher_type != "Exchange Gain Or Loss":
as_dict=True, frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(row.reference_name, row.account)
) )
return
if not against_entries: dr_or_cr = "debit" if flt(row.credit) > 0 else "credit"
if self.voucher_type != "Exchange Gain Or Loss": has_unmatched_entry = any(flt(entry[dr_or_cr]) > 0 for entry in against_entries)
frappe.throw( if not has_unmatched_entry and not self.system_generated_gain_loss():
_( frappe.throw(
"Journal Entry {0} does not have account {1} or already matched against other voucher" _("Against Journal Entry {0} does not have any unmatched {1} entry").format(
).format(d.reference_name, d.account) row.reference_name, dr_or_cr
) )
else: )
dr_or_cr = "debit" if flt(d.credit) > 0 else "credit"
valid = False def _validate_jv_reference_direction(self, row) -> None:
for jvd in against_entries: """An asset account can reference a JE only when credited, a liability only when debited."""
if flt(jvd[dr_or_cr]) > 0: if self.system_generated_gain_loss():
valid = True return
if not valid and not self.system_generated_gain_loss():
frappe.throw( account_root_type = frappe.get_cached_value("Account", row.account, "root_type")
_("Against Journal Entry {0} does not have any unmatched {1} entry").format( if account_root_type == "Asset" and flt(row.debit) > 0:
d.reference_name, dr_or_cr frappe.throw(
) _(
) "Row #{0}: For {1}, you can select reference document only if account gets credited"
).format(row.idx, row.account)
)
if account_root_type == "Liability" and flt(row.credit) > 0:
frappe.throw(
_("Row #{0}: For {1}, you can select reference document only if account gets debited").format(
row.idx, row.account
)
)
def _get_against_jv_entries(self, row) -> list[dict]:
"""Submitted Journal Entry Account rows on the referenced JE for the same account
that are not themselves linked to an order."""
jea = frappe.qb.DocType("Journal Entry Account")
return (
frappe.qb.from_(jea)
.select(jea.star)
.where(
(jea.account == row.account)
& (jea.docstatus == 1)
& (jea.parent == row.reference_name)
& (
jea.reference_type.isnull()
| jea.reference_type.isin(["", "Sales Order", "Purchase Order"])
)
)
.run(as_dict=True)
)
def set_against_account(self): def set_against_account(self):
accounts_debited, accounts_credited = [], [] accounts_debited, accounts_credited = [], []
@@ -686,131 +712,142 @@ class JournalEntry(AccountsController):
d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit")) d.debit = flt(d.debit_in_account_currency * flt(d.exchange_rate), d.precision("debit"))
d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit")) d.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit"))
def set_exchange_rate(self): def set_exchange_rate(self) -> None:
for d in self.get("accounts"): """Resolve a mandatory exchange rate for every account row."""
if d.account_currency == self.company_currency: for row in self.get("accounts"):
d.exchange_rate = 1 self._set_row_exchange_rate(row)
elif ( if not row.exchange_rate:
not d.exchange_rate frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(row.idx))
or d.exchange_rate == 1
or (
d.reference_type in ("Sales Invoice", "Purchase Invoice")
and d.reference_name
and self.posting_date
)
):
ignore_exchange_rate = False
if self.get("flags") and self.flags.get("ignore_exchange_rate"):
ignore_exchange_rate = True
if not ignore_exchange_rate: def _set_row_exchange_rate(self, row) -> None:
# Modified to include the posting date for which to retreive the exchange rate """Set a row's exchange rate: 1 for company currency, otherwise fetched when stale."""
d.exchange_rate = get_exchange_rate( if row.account_currency == self.company_currency:
self.posting_date, row.exchange_rate = 1
d.account,
d.account_currency,
self.company,
d.reference_type,
d.reference_name,
d.debit,
d.credit,
d.exchange_rate,
)
if not d.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx))
def create_remarks(self):
r = []
if self.flags.skip_remarks_creation:
return return
if self.get("custom_remark"): needs_refresh = (
return not row.exchange_rate
or row.exchange_rate == 1
if self.cheque_no: or (
if self.cheque_date: row.reference_type in ("Sales Invoice", "Purchase Invoice")
r.append(_("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date))) and row.reference_name
else: and self.posting_date
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
for d in self.get("accounts"):
if d.reference_type == "Sales Invoice" and d.credit:
r.append(
_("{0} against Sales Invoice {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Sales Order" and d.credit:
r.append(
_("{0} against Sales Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if d.reference_type == "Purchase Invoice" and d.debit:
bill_no = frappe.db.sql(
"""select bill_no, bill_date
from `tabPurchase Invoice` where name=%s""",
d.reference_name,
)
if (
bill_no
and bill_no[0][0]
and bill_no[0][0].lower().strip() not in ["na", "not applicable", "none"]
):
r.append(
_("{0} against Bill {1} dated {2}").format(
fmt_money(flt(d.debit), currency=self.company_currency),
bill_no[0][0],
bill_no[0][1] and formatdate(bill_no[0][1].strftime("%Y-%m-%d")),
)
)
if d.reference_type == "Purchase Order" and d.debit:
r.append(
_("{0} against Purchase Order {1}").format(
fmt_money(flt(d.credit), currency=self.company_currency), d.reference_name
)
)
if r:
self.remark = ("\n").join(r) # User Remarks is not mandatory
def set_print_format_fields(self):
bank_amount = party_amount = total_amount = 0.0
currency = bank_account_currency = party_account_currency = pay_to_recd_from = None
party_type = None
for d in self.get("accounts"):
if d.party_type in ["Customer", "Supplier"] and d.party:
party_type = d.party_type
if not pay_to_recd_from:
pay_to_recd_from = d.party
if pay_to_recd_from and pay_to_recd_from == d.party:
party_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
party_account_currency = d.account_currency
elif frappe.get_cached_value("Account", d.account, "account_type") in ["Bank", "Cash"]:
bank_amount += flt(d.debit_in_account_currency) or flt(d.credit_in_account_currency)
bank_account_currency = d.account_currency
if party_type and pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
party_type, pay_to_recd_from, "customer_name" if party_type == "Customer" else "supplier_name"
) )
if bank_amount: )
total_amount = bank_amount if not needs_refresh or self.flags.get("ignore_exchange_rate"):
currency = bank_account_currency return
# Includes the posting date for which to retrieve the exchange rate
row.exchange_rate = get_exchange_rate(
self.posting_date,
row.account,
row.account_currency,
self.company,
row.reference_type,
row.reference_name,
row.debit,
row.credit,
row.exchange_rate,
)
def create_remarks(self) -> None:
"""Build the auto remark from the cheque reference and each account row's linked
document, unless remark creation is skipped or a custom remark is set."""
if self.flags.skip_remarks_creation or self.get("custom_remark"):
return
remarks = []
if cheque_remark := self._get_cheque_remark():
remarks.append(cheque_remark)
for row in self.get("accounts"):
if reference_remark := self._get_reference_remark(row):
remarks.append(reference_remark)
if remarks:
self.remark = "\n".join(remarks) # User Remarks is not mandatory
def _get_cheque_remark(self) -> str | None:
"""Remark line for the cheque reference; raises if the cheque date is missing."""
if not self.cheque_no:
return None
if not self.cheque_date:
msgprint(_("Please enter Reference date"), raise_exception=frappe.MandatoryError)
return _("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date))
def _get_reference_remark(self, row) -> str | None:
"""Remark line for a single account row's linked Invoice/Order, or None."""
if row.reference_type == "Sales Invoice" and row.credit:
return _("{0} against Sales Invoice {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Sales Order" and row.credit:
return _("{0} against Sales Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
if row.reference_type == "Purchase Invoice" and row.debit:
return self._get_bill_remark(row)
if row.reference_type == "Purchase Order" and row.debit:
return _("{0} against Purchase Order {1}").format(
fmt_money(flt(row.credit), currency=self.company_currency), row.reference_name
)
return None
def _get_bill_remark(self, row) -> str | None:
"""Remark line referencing the supplier bill number/date of a Purchase Invoice row."""
bill_no, bill_date = frappe.db.get_value(
"Purchase Invoice", row.reference_name, ["bill_no", "bill_date"]
) or (None, None)
if not bill_no or bill_no.lower().strip() in ["na", "not applicable", "none"]:
return None
return _("{0} against Bill {1} dated {2}").format(
fmt_money(flt(row.debit), currency=self.company_currency),
bill_no,
bill_date and formatdate(bill_date.strftime("%Y-%m-%d")),
)
def set_print_format_fields(self) -> None:
"""Populate pay_to_recd_from and the total amount/currency shown on the print format."""
amounts = self._get_party_and_bank_amounts()
total_amount, currency = 0.0, None
if amounts.party_type and amounts.pay_to_recd_from:
self.pay_to_recd_from = frappe.db.get_value(
amounts.party_type,
amounts.pay_to_recd_from,
"customer_name" if amounts.party_type == "Customer" else "supplier_name",
)
if amounts.bank_amount:
total_amount, currency = amounts.bank_amount, amounts.bank_account_currency
else: else:
total_amount = party_amount total_amount, currency = amounts.party_amount, amounts.party_account_currency
currency = party_account_currency
self.set_total_amount(total_amount, currency) self.set_total_amount(total_amount, currency)
def set_total_amount(self, amt, currency): def _get_party_and_bank_amounts(self) -> frappe._dict:
"""Sum the party and bank/cash amounts, with their currencies, across the account rows."""
totals = frappe._dict(
bank_amount=0.0,
party_amount=0.0,
bank_account_currency=None,
party_account_currency=None,
pay_to_recd_from=None,
party_type=None,
)
for row in self.get("accounts"):
amount = flt(row.debit_in_account_currency) or flt(row.credit_in_account_currency)
if row.party_type in ["Customer", "Supplier"] and row.party:
totals.party_type = row.party_type
totals.pay_to_recd_from = totals.pay_to_recd_from or row.party
if totals.pay_to_recd_from == row.party:
totals.party_amount += amount
totals.party_account_currency = row.account_currency
elif frappe.get_cached_value("Account", row.account, "account_type") in ["Bank", "Cash"]:
totals.bank_amount += amount
totals.bank_account_currency = row.account_currency
return totals
def set_total_amount(self, amt: float, currency: str) -> None:
self.total_amount = amt self.total_amount = amt
self.total_amount_currency = currency self.total_amount_currency = currency
from frappe.utils import money_in_words from frappe.utils import money_in_words
@@ -822,7 +859,7 @@ class JournalEntry(AccountsController):
return JournalEntryGLComposer(self).compose() return JournalEntryGLComposer(self).compose()
def make_gl_entries(self, cancel=0, adv_adj=0): def make_gl_entries(self, cancel: int = 0, adv_adj: int = 0) -> None:
from erpnext.accounts.general_ledger import make_gl_entries from erpnext.accounts.general_ledger import make_gl_entries
merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads") merge_entries = frappe.get_single_value("Accounts Settings", "merge_similar_account_heads")
@@ -846,94 +883,109 @@ class JournalEntry(AccountsController):
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name)) cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
@frappe.whitelist() @frappe.whitelist()
def get_balance(self, difference_account: str | None = None): def get_balance(self, difference_account: str | None = None) -> None:
"""Balance the entry by placing any difference on a blank (or newly added) row."""
if not self.get("accounts"): if not self.get("accounts"):
msgprint(_("'Entries' cannot be empty"), raise_exception=True) msgprint(_("'Entries' cannot be empty"), raise_exception=True)
else: return
self.total_debit, self.total_credit = 0, 0
diff = flt(self.difference, self.precision("difference"))
# If any row without amount, set the diff on that row self.total_debit, self.total_credit = 0, 0
if diff: diff = flt(self.difference, self.precision("difference"))
blank_row = None if diff:
for d in self.get("accounts"): self._apply_difference_to_blank_row(diff, difference_account)
if not d.credit_in_account_currency and not d.debit_in_account_currency and diff != 0:
blank_row = d
if not blank_row: self.set_total_debit_credit()
blank_row = self.append( self.validate_total_debit_and_credit()
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
blank_row.exchange_rate = 1 def _apply_difference_to_blank_row(self, diff: float, difference_account: str | None) -> None:
if diff > 0: """Set the balancing difference on the last amountless row, adding one if none exists."""
blank_row.credit_in_account_currency = diff blank_row = None
blank_row.credit = diff for row in self.get("accounts"):
elif diff < 0: if not row.credit_in_account_currency and not row.debit_in_account_currency:
blank_row.debit_in_account_currency = abs(diff) blank_row = row
blank_row.debit = abs(diff)
self.set_total_debit_credit() if not blank_row:
self.validate_total_debit_and_credit() blank_row = self.append(
"accounts",
{
"account": difference_account,
"cost_center": erpnext.get_default_cost_center(self.company),
},
)
blank_row.exchange_rate = 1
if diff > 0:
blank_row.credit_in_account_currency = diff
blank_row.credit = diff
elif diff < 0:
blank_row.debit_in_account_currency = abs(diff)
blank_row.debit = abs(diff)
@frappe.whitelist() @frappe.whitelist()
def get_outstanding_invoices(self): def get_outstanding_invoices(self) -> None:
"""Populate the entry with a write-off row per outstanding invoice plus a balancing row."""
self.set("accounts", []) self.set("accounts", [])
total = 0 total = 0
for d in self.get_values(): for invoice in self.get_values():
total += flt(d.outstanding_amount, self.precision("credit", "accounts")) total += flt(invoice.outstanding_amount, self.precision("credit", "accounts"))
jd1 = self.append("accounts", {}) self._append_outstanding_invoice_row(invoice)
jd1.account = d.account
jd1.party = d.party
if self.write_off_based_on == "Accounts Receivable": balancing_row = self.append("accounts", {})
jd1.party_type = "Customer"
jd1.credit_in_account_currency = flt(
d.outstanding_amount, self.precision("credit", "accounts")
)
jd1.reference_type = "Sales Invoice"
jd1.reference_name = cstr(d.name)
elif self.write_off_based_on == "Accounts Payable":
jd1.party_type = "Supplier"
jd1.debit_in_account_currency = flt(d.outstanding_amount, self.precision("debit", "accounts"))
jd1.reference_type = "Purchase Invoice"
jd1.reference_name = cstr(d.name)
jd2 = self.append("accounts", {})
if self.write_off_based_on == "Accounts Receivable": if self.write_off_based_on == "Accounts Receivable":
jd2.debit_in_account_currency = total balancing_row.debit_in_account_currency = total
elif self.write_off_based_on == "Accounts Payable": elif self.write_off_based_on == "Accounts Payable":
jd2.credit_in_account_currency = total balancing_row.credit_in_account_currency = total
self.validate_total_debit_and_credit() self.validate_total_debit_and_credit()
def get_values(self): def _append_outstanding_invoice_row(self, invoice) -> None:
cond = ( """Append a party row for a single outstanding invoice per the write-off basis."""
f" and outstanding_amount <= {flt(self.write_off_amount)}" row = self.append("accounts", {})
if flt(self.write_off_amount) > 0 row.account = invoice.account
else "" row.party = invoice.party
)
if self.write_off_based_on == "Accounts Receivable": if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql( row.party_type = "Customer"
"""select name, debit_to as account, customer as party, outstanding_amount row.credit_in_account_currency = flt(
from `tabSales Invoice` where docstatus = 1 and company = {} invoice.outstanding_amount, self.precision("credit", "accounts")
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
) )
row.reference_type = "Sales Invoice"
row.reference_name = cstr(invoice.name)
elif self.write_off_based_on == "Accounts Payable": elif self.write_off_based_on == "Accounts Payable":
return frappe.db.sql( row.party_type = "Supplier"
"""select name, credit_to as account, supplier as party, outstanding_amount row.debit_in_account_currency = flt(
from `tabPurchase Invoice` where docstatus = 1 and company = {} invoice.outstanding_amount, self.precision("debit", "accounts")
and outstanding_amount > 0 {}""".format("%s", cond),
self.company,
as_dict=True,
) )
row.reference_type = "Purchase Invoice"
row.reference_name = cstr(invoice.name)
def get_values(self):
if self.write_off_based_on == "Accounts Receivable":
doctype, account_field, party_field = "Sales Invoice", "debit_to", "customer"
elif self.write_off_based_on == "Accounts Payable":
doctype, account_field, party_field = "Purchase Invoice", "credit_to", "supplier"
else:
return
invoice = frappe.qb.DocType(doctype)
query = (
frappe.qb.from_(invoice)
.select(
invoice.name,
invoice[account_field].as_("account"),
invoice[party_field].as_("party"),
invoice.outstanding_amount,
)
.where(
(invoice.docstatus == 1)
& (invoice.company == self.company)
& (invoice.outstanding_amount > 0)
)
)
if flt(self.write_off_amount) > 0:
query = query.where(invoice.outstanding_amount <= flt(self.write_off_amount))
return query.run(as_dict=True)
def validate_credit_debit_note(self): def validate_credit_debit_note(self):
if self.stock_entry: if self.stock_entry:
@@ -962,7 +1014,7 @@ def get_default_bank_cash_account(
account: str | None = None, account: str | None = None,
*, *,
fetch_balance: bool = True, fetch_balance: bool = True,
): ) -> dict:
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
if mode_of_payment: if mode_of_payment:
@@ -1017,7 +1069,8 @@ def get_against_jv(
start: int, start: int,
page_len: int, page_len: int,
filters: dict, filters: dict,
): ) -> list:
"""Link-field search for submitted Journal Entries having an unreferenced row on an account."""
if not frappe.db.has_column("Journal Entry", searchfield): if not frappe.db.has_column("Journal Entry", searchfield):
return [] return []
@@ -1048,67 +1101,97 @@ def get_against_jv(
@frappe.whitelist() @frappe.whitelist()
def get_outstanding(args: str | dict): def get_outstanding(
doctype: str | None = None,
docname: str | None = None,
company: str | None = None,
account: str | None = None,
party: str | None = None,
account_currency: str | None = None,
**kwargs,
) -> dict | None:
"""Return the outstanding amount and side to set when referencing a JV / Invoice.
The named parameters are the supported interface. The legacy `args` payload dict
(captured via kwargs) is still accepted for backward compatibility with callers,
including custom apps, and is unpacked into the named parameters below.
"""
if not frappe.has_permission("Account"): if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1) frappe.msgprint(_("No Permission"), raise_exception=1)
if isinstance(args, str): if legacy_payload := kwargs.get("args"):
args = json.loads(args) if isinstance(legacy_payload, str):
legacy_payload = json.loads(legacy_payload)
doctype = legacy_payload.get("doctype")
docname = legacy_payload.get("docname")
company = legacy_payload.get("company")
account = legacy_payload.get("account")
party = legacy_payload.get("party")
account_currency = legacy_payload.get("account_currency")
company_currency = erpnext.get_company_currency(args.get("company")) if doctype == "Journal Entry":
due_date = None return _get_journal_entry_outstanding(docname, account, party)
if args.get("doctype") == "Journal Entry": if doctype in ("Sales Invoice", "Purchase Invoice"):
condition = " and party=%(party)s" if args.get("party") else "" return _get_invoice_outstanding(doctype, docname, company, account_currency)
against_jv_amount = frappe.db.sql(
f""" def _get_journal_entry_outstanding(docname: str, account: str | None, party: str | None) -> dict:
select sum(debit_in_account_currency) - sum(credit_in_account_currency) """Unreferenced debit-minus-credit balance for an account on a Journal Entry."""
from `tabJournal Entry Account` where parent=%(docname)s and account=%(account)s {condition} jea = frappe.qb.DocType("Journal Entry Account")
and (reference_type is null or reference_type = '')""", query = (
args, frappe.qb.from_(jea)
.select(Sum(jea.debit_in_account_currency) - Sum(jea.credit_in_account_currency))
.where(
(jea.parent == docname)
& (jea.account == account)
& (jea.reference_type.isnull() | (jea.reference_type == ""))
)
)
if party:
query = query.where(jea.party == party)
result = query.run()
balance = flt(result[0][0]) if result else 0
amount_field = "credit_in_account_currency" if balance > 0 else "debit_in_account_currency"
return {amount_field: abs(balance)}
def _get_invoice_outstanding(doctype: str, docname: str, company: str, account_currency: str | None) -> dict:
"""Outstanding amount, side, party and exchange rate for a Sales/Purchase Invoice."""
party_type = "Customer" if doctype == "Sales Invoice" else "Supplier"
invoice = frappe.db.get_value(
doctype,
docname,
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
company_currency = erpnext.get_company_currency(company)
exchange_rate = invoice.conversion_rate if account_currency != company_currency else 1
outstanding_is_positive = flt(invoice.outstanding_amount) > 0
if doctype == "Sales Invoice":
amount_field = (
"credit_in_account_currency" if outstanding_is_positive else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency" if outstanding_is_positive else "credit_in_account_currency"
) )
against_jv_amount = flt(against_jv_amount[0][0]) if against_jv_amount else 0 return {
amount_field = "credit_in_account_currency" if against_jv_amount > 0 else "debit_in_account_currency" amount_field: abs(flt(invoice.outstanding_amount)),
return {amount_field: abs(against_jv_amount)} "exchange_rate": exchange_rate,
elif args.get("doctype") in ("Sales Invoice", "Purchase Invoice"): "party_type": party_type,
party_type = "Customer" if args.get("doctype") == "Sales Invoice" else "Supplier" "party": invoice.get(scrub(party_type)),
invoice = frappe.db.get_value( "reference_due_date": invoice.get("due_date"),
args["doctype"], }
args["docname"],
["outstanding_amount", "conversion_rate", scrub(party_type), "due_date"],
as_dict=1,
)
due_date = invoice.get("due_date")
exchange_rate = invoice.conversion_rate if (args.get("account_currency") != company_currency) else 1
if args["doctype"] == "Sales Invoice":
amount_field = (
"credit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "debit_in_account_currency"
)
else:
amount_field = (
"debit_in_account_currency"
if flt(invoice.outstanding_amount) > 0
else "credit_in_account_currency"
)
return {
amount_field: abs(flt(invoice.outstanding_amount)),
"exchange_rate": exchange_rate,
"party_type": party_type,
"party": invoice.get(scrub(party_type)),
"reference_due_date": due_date,
}
@frappe.whitelist() @frappe.whitelist()
def get_party_account_and_currency(company: str, party_type: str, party: str): def get_party_account_and_currency(company: str, party_type: str, party: str) -> dict:
"""Return the receivable/payable account for a party and its account currency."""
if not frappe.has_permission("Account"): if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1) frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1128,7 +1211,7 @@ def get_account_details_and_party_type(
debit: float | str | None = None, debit: float | str | None = None,
credit: float | str | None = None, credit: float | str | None = None,
exchange_rate: float | str | None = None, exchange_rate: float | str | None = None,
): ) -> dict:
"""Returns dict of account details and party type to be set in Journal Entry on selection of account.""" """Returns dict of account details and party type to be set in Journal Entry on selection of account."""
if not frappe.has_permission("Account"): if not frappe.has_permission("Account"):
frappe.msgprint(_("No Permission"), raise_exception=1) frappe.msgprint(_("No Permission"), raise_exception=1)
@@ -1186,7 +1269,8 @@ def get_exchange_rate(
debit: float | str | None = None, debit: float | str | None = None,
credit: float | str | None = None, credit: float | str | None = None,
exchange_rate: str | float | None = None, exchange_rate: str | float | None = None,
): ) -> float:
"""Resolve the exchange rate for an account row, by reference, balance or settings."""
# Ensure exchange_rate is always numeric to avoid calculation errors # Ensure exchange_rate is always numeric to avoid calculation errors
if isinstance(exchange_rate, str): if isinstance(exchange_rate, str):
exchange_rate = flt(exchange_rate) or 1 exchange_rate = flt(exchange_rate) or 1
@@ -1219,14 +1303,3 @@ def get_exchange_rate(
# don't return None or 0 as it is multipled with a value and that value could be lost # don't return None or 0 as it is multipled with a value and that value could be lost
return exchange_rate or 1 return exchange_rate or 1
@frappe.whitelist()
def get_average_exchange_rate(account: str):
exchange_rate = 0
bank_balance_in_account_currency = get_balance_on(account)
if bank_balance_in_account_currency:
bank_balance_in_company_currency = get_balance_on(account, in_account_currency=False)
exchange_rate = bank_balance_in_company_currency / bank_balance_in_account_currency
return exchange_rate

View File

@@ -688,6 +688,67 @@ class TestJournalEntry(ERPNextTestSuite):
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice") self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC") self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
def test_get_balance_places_difference_on_blank_row(self):
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"debit_in_account_currency": 100,
"debit": 100,
"exchange_rate": 1,
},
)
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
jv.set_total_debit_credit()
self.assertEqual(jv.difference, 100)
jv.get_balance()
blank_row = jv.accounts[1]
self.assertEqual(blank_row.credit_in_account_currency, 100)
self.assertEqual(jv.total_debit, jv.total_credit)
def test_get_outstanding_invoices_builds_write_off_rows(self):
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = frappe.new_doc("Journal Entry")
jv.company = "_Test Company"
jv.posting_date = nowdate()
jv.voucher_type = "Write Off Entry"
jv.write_off_based_on = "Accounts Receivable"
jv.write_off_amount = 1000
jv.get_outstanding_invoices()
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
self.assertTrue(invoice_rows)
self.assertEqual(invoice_rows[0].party_type, "Customer")
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
def test_unlink_advance_entry_reference_on_cancel(self):
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=700)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
advance_row = jv.accounts[1]
advance_row.party_type = "Customer"
advance_row.party = "_Test Customer"
advance_row.is_advance = "Yes"
advance_row.reference_type = "Sales Invoice"
advance_row.reference_name = invoice.name
jv.submit()
jv.cancel()
jv.reload()
self.assertFalse(jv.accounts[1].reference_type)
self.assertFalse(jv.accounts[1].reference_name)
def make_journal_entry( def make_journal_entry(
account1, account1,