mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-11 00:43:04 +00:00
Compare commits
25 Commits
copilot/fi
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a816d19cb | ||
|
|
a7d41f24a3 | ||
|
|
81a1c2c8ce | ||
|
|
0c6f7fed55 | ||
|
|
bfee9df9aa | ||
|
|
bddd1d0ebc | ||
|
|
aa9f225c41 | ||
|
|
9c799f31ff | ||
|
|
a60afaf91a | ||
|
|
a4cff805f1 | ||
|
|
4f55071eda | ||
|
|
43bb6c5a42 | ||
|
|
34955380ee | ||
|
|
1714e13b39 | ||
|
|
263c3e9dd4 | ||
|
|
c97c2d1e02 | ||
|
|
cf37478870 | ||
|
|
060a5c4eeb | ||
|
|
f099dbad35 | ||
|
|
cc8ce03232 | ||
|
|
bcc1e73962 | ||
|
|
32d7250946 | ||
|
|
4c1cabb53e | ||
|
|
1105cb8ddf | ||
|
|
8bb4ffc6b1 |
@@ -409,18 +409,16 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
||||
}
|
||||
|
||||
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({
|
||||
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) {
|
||||
if (r.message) {
|
||||
$.each(r.message, function (field, value) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import frappe
|
||||
from frappe import _, msgprint, scrub
|
||||
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
|
||||
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
|
||||
|
||||
import erpnext
|
||||
@@ -43,6 +44,14 @@ class StockAccountInvalidTransaction(frappe.ValidationError):
|
||||
|
||||
|
||||
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
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
@@ -128,6 +137,7 @@ class JournalEntry(AccountsController):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
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.reference_validator import (
|
||||
JournalEntryReferenceValidator,
|
||||
@@ -188,28 +198,33 @@ class JournalEntry(AccountsController):
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
|
||||
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:
|
||||
queue_submission(self, "_submit")
|
||||
else:
|
||||
return self._submit()
|
||||
|
||||
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
|
||||
|
||||
AssetService(self).has_asset_adjustment_entry()
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel inline, or queue cancellation in the background for large entries."""
|
||||
if len(self.accounts) > 100:
|
||||
queue_submission(self, "_cancel")
|
||||
else:
|
||||
return self._cancel()
|
||||
|
||||
def before_submit(self):
|
||||
"""Ensure total debit equals total credit before submission (skipped on data import)."""
|
||||
# Do not validate while importing via data import
|
||||
if not frappe.flags.in_import:
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
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
|
||||
|
||||
self.validate_cheque_info()
|
||||
@@ -221,18 +236,16 @@ class JournalEntry(AccountsController):
|
||||
JournalTaxWithholding(self).on_submit()
|
||||
|
||||
@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()
|
||||
|
||||
stock_accounts = self.get_stock_accounts_for_periodic_accounting()
|
||||
self.set("accounts", [])
|
||||
for account in stock_accounts:
|
||||
account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(
|
||||
for account in self.get_stock_accounts_for_periodic_accounting():
|
||||
account_bal, stock_bal, _warehouse_list = get_stock_and_account_balance(
|
||||
account, self.posting_date, self.company
|
||||
)
|
||||
|
||||
difference_value = flt(stock_bal - account_bal, self.precision("difference"))
|
||||
|
||||
if difference_value == 0:
|
||||
frappe.msgprint(
|
||||
_("No difference found for stock account {0}").format(frappe.bold(account)),
|
||||
@@ -240,23 +253,26 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
continue
|
||||
|
||||
self.append(
|
||||
"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_periodic_difference_rows(account, difference_value)
|
||||
|
||||
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 _append_periodic_difference_rows(self, account: str, difference_value: float) -> None:
|
||||
"""Append the stock account row and its offsetting difference-account row."""
|
||||
self.append(
|
||||
"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(
|
||||
"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):
|
||||
if erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
@@ -302,6 +318,7 @@ class JournalEntry(AccountsController):
|
||||
self.repost_accounting_entries()
|
||||
|
||||
def on_cancel(self):
|
||||
"""Reverse GL entries and unlink asset, inter-company and advance references on cancel."""
|
||||
# Cancel tax withholding entries
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
@@ -385,49 +402,44 @@ class JournalEntry(AccountsController):
|
||||
self.name,
|
||||
)
|
||||
|
||||
def update_invoice_discounting(self):
|
||||
def _validate_invoice_discounting_status(inv_disc, id_status, expected_status, row_id):
|
||||
id_link = get_link_to_form("Invoice Discounting", inv_disc)
|
||||
if id_status != expected_status:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
|
||||
d.idx, expected_status, id_link
|
||||
)
|
||||
)
|
||||
def update_invoice_discounting(self) -> None:
|
||||
"""Advance each linked Invoice Discounting to its next status on submit/cancel."""
|
||||
discounting_names = {
|
||||
row.reference_name for row in self.accounts if row.reference_type == "Invoice Discounting"
|
||||
}
|
||||
for name in discounting_names:
|
||||
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(
|
||||
set([d.reference_name for d in self.accounts if d.reference_type == "Invoice Discounting"])
|
||||
)
|
||||
for inv_disc in invoice_discounting_list:
|
||||
inv_disc_doc = frappe.get_doc("Invoice Discounting", inv_disc)
|
||||
status = None
|
||||
for d in self.accounts:
|
||||
if d.account == inv_disc_doc.short_term_loan and d.reference_name == inv_disc:
|
||||
if self.docstatus == 1:
|
||||
if d.credit > 0:
|
||||
_validate_invoice_discounting_status(
|
||||
inv_disc, inv_disc_doc.status, "Sanctioned", d.idx
|
||||
)
|
||||
status = "Disbursed"
|
||||
elif d.debit > 0:
|
||||
_validate_invoice_discounting_status(
|
||||
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
|
||||
)
|
||||
status = "Settled"
|
||||
else:
|
||||
if d.credit > 0:
|
||||
_validate_invoice_discounting_status(
|
||||
inv_disc, inv_disc_doc.status, "Disbursed", d.idx
|
||||
)
|
||||
status = "Sanctioned"
|
||||
elif d.debit > 0:
|
||||
_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 _get_next_invoice_discounting_status(self, inv_disc) -> str | None:
|
||||
"""Validate the current status and return the next one from the loan account row."""
|
||||
for row in self.accounts:
|
||||
if row.account != inv_disc.short_term_loan or row.reference_name != inv_disc.name:
|
||||
continue
|
||||
|
||||
submitting = self.docstatus == 1
|
||||
if row.credit > 0:
|
||||
expected, next_status = (
|
||||
("Sanctioned", "Disbursed") if submitting else ("Disbursed", "Sanctioned")
|
||||
)
|
||||
elif row.debit > 0:
|
||||
expected, next_status = ("Disbursed", "Settled") if submitting else ("Settled", "Disbursed")
|
||||
else:
|
||||
return None
|
||||
|
||||
self._validate_invoice_discounting_status(inv_disc, expected, row.idx)
|
||||
return next_status
|
||||
return None
|
||||
|
||||
def _validate_invoice_discounting_status(self, inv_disc, expected_status: str, row_idx: int) -> None:
|
||||
"""Throw unless the Invoice Discounting is in the status expected for this transition."""
|
||||
if inv_disc.status != expected_status:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Status must be {1} for Invoice Discounting {2}").format(
|
||||
row_idx, expected_status, get_link_to_form("Invoice Discounting", inv_disc.name)
|
||||
)
|
||||
)
|
||||
|
||||
def unlink_advance_entry_reference(self):
|
||||
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
|
||||
)
|
||||
|
||||
def validate_against_jv(self):
|
||||
for d in self.get("accounts"):
|
||||
if d.reference_type == "Journal Entry":
|
||||
account_root_type = frappe.get_cached_value("Account", d.account, "root_type")
|
||||
if (
|
||||
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)
|
||||
)
|
||||
def validate_against_jv(self) -> None:
|
||||
"""Validate every account row that references another Journal Entry."""
|
||||
for row in self.get("accounts"):
|
||||
if row.reference_type == "Journal Entry":
|
||||
self._validate_jv_reference(row)
|
||||
|
||||
if d.reference_name == self.name:
|
||||
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
|
||||
def _validate_jv_reference(self, row) -> None:
|
||||
"""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(
|
||||
"""select * from `tabJournal Entry Account`
|
||||
where account = %s and docstatus = 1 and parent = %s
|
||||
and (reference_type is null or reference_type in ('', 'Sales Order', 'Purchase Order'))
|
||||
""",
|
||||
(d.account, d.reference_name),
|
||||
as_dict=True,
|
||||
if row.reference_name == self.name:
|
||||
frappe.throw(_("You can not enter current voucher in 'Against Journal Entry' column"))
|
||||
|
||||
against_entries = self._get_against_jv_entries(row)
|
||||
if not against_entries:
|
||||
if self.voucher_type != "Exchange Gain Or Loss":
|
||||
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:
|
||||
if self.voucher_type != "Exchange Gain Or Loss":
|
||||
frappe.throw(
|
||||
_(
|
||||
"Journal Entry {0} does not have account {1} or already matched against other voucher"
|
||||
).format(d.reference_name, d.account)
|
||||
)
|
||||
else:
|
||||
dr_or_cr = "debit" if flt(d.credit) > 0 else "credit"
|
||||
valid = False
|
||||
for jvd in against_entries:
|
||||
if flt(jvd[dr_or_cr]) > 0:
|
||||
valid = True
|
||||
if not valid and not self.system_generated_gain_loss():
|
||||
frappe.throw(
|
||||
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
|
||||
d.reference_name, dr_or_cr
|
||||
)
|
||||
)
|
||||
dr_or_cr = "debit" if flt(row.credit) > 0 else "credit"
|
||||
has_unmatched_entry = any(flt(entry[dr_or_cr]) > 0 for entry in against_entries)
|
||||
if not has_unmatched_entry and not self.system_generated_gain_loss():
|
||||
frappe.throw(
|
||||
_("Against Journal Entry {0} does not have any unmatched {1} entry").format(
|
||||
row.reference_name, dr_or_cr
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_jv_reference_direction(self, row) -> None:
|
||||
"""An asset account can reference a JE only when credited, a liability only when debited."""
|
||||
if self.system_generated_gain_loss():
|
||||
return
|
||||
|
||||
account_root_type = frappe.get_cached_value("Account", row.account, "root_type")
|
||||
if account_root_type == "Asset" and flt(row.debit) > 0:
|
||||
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):
|
||||
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.credit = flt(d.credit_in_account_currency * flt(d.exchange_rate), d.precision("credit"))
|
||||
|
||||
def set_exchange_rate(self):
|
||||
for d in self.get("accounts"):
|
||||
if d.account_currency == self.company_currency:
|
||||
d.exchange_rate = 1
|
||||
elif (
|
||||
not d.exchange_rate
|
||||
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
|
||||
def set_exchange_rate(self) -> None:
|
||||
"""Resolve a mandatory exchange rate for every account row."""
|
||||
for row in self.get("accounts"):
|
||||
self._set_row_exchange_rate(row)
|
||||
if not row.exchange_rate:
|
||||
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(row.idx))
|
||||
|
||||
if not ignore_exchange_rate:
|
||||
# Modified to include the posting date for which to retreive the exchange rate
|
||||
d.exchange_rate = get_exchange_rate(
|
||||
self.posting_date,
|
||||
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:
|
||||
def _set_row_exchange_rate(self, row) -> None:
|
||||
"""Set a row's exchange rate: 1 for company currency, otherwise fetched when stale."""
|
||||
if row.account_currency == self.company_currency:
|
||||
row.exchange_rate = 1
|
||||
return
|
||||
|
||||
if self.get("custom_remark"):
|
||||
return
|
||||
|
||||
if self.cheque_no:
|
||||
if self.cheque_date:
|
||||
r.append(_("Reference #{0} dated {1}").format(self.cheque_no, formatdate(self.cheque_date)))
|
||||
else:
|
||||
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"
|
||||
needs_refresh = (
|
||||
not row.exchange_rate
|
||||
or row.exchange_rate == 1
|
||||
or (
|
||||
row.reference_type in ("Sales Invoice", "Purchase Invoice")
|
||||
and row.reference_name
|
||||
and self.posting_date
|
||||
)
|
||||
if bank_amount:
|
||||
total_amount = bank_amount
|
||||
currency = bank_account_currency
|
||||
)
|
||||
if not needs_refresh or self.flags.get("ignore_exchange_rate"):
|
||||
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:
|
||||
total_amount = party_amount
|
||||
currency = party_account_currency
|
||||
total_amount, currency = amounts.party_amount, amounts.party_account_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_currency = currency
|
||||
from frappe.utils import money_in_words
|
||||
@@ -822,7 +859,7 @@ class JournalEntry(AccountsController):
|
||||
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
@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"):
|
||||
msgprint(_("'Entries' cannot be empty"), raise_exception=True)
|
||||
else:
|
||||
self.total_debit, self.total_credit = 0, 0
|
||||
diff = flt(self.difference, self.precision("difference"))
|
||||
return
|
||||
|
||||
# If any row without amount, set the diff on that row
|
||||
if diff:
|
||||
blank_row = None
|
||||
for d in self.get("accounts"):
|
||||
if not d.credit_in_account_currency and not d.debit_in_account_currency and diff != 0:
|
||||
blank_row = d
|
||||
self.total_debit, self.total_credit = 0, 0
|
||||
diff = flt(self.difference, self.precision("difference"))
|
||||
if diff:
|
||||
self._apply_difference_to_blank_row(diff, difference_account)
|
||||
|
||||
if not blank_row:
|
||||
blank_row = self.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": difference_account,
|
||||
"cost_center": erpnext.get_default_cost_center(self.company),
|
||||
},
|
||||
)
|
||||
self.set_total_debit_credit()
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
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)
|
||||
def _apply_difference_to_blank_row(self, diff: float, difference_account: str | None) -> None:
|
||||
"""Set the balancing difference on the last amountless row, adding one if none exists."""
|
||||
blank_row = None
|
||||
for row in self.get("accounts"):
|
||||
if not row.credit_in_account_currency and not row.debit_in_account_currency:
|
||||
blank_row = row
|
||||
|
||||
self.set_total_debit_credit()
|
||||
self.validate_total_debit_and_credit()
|
||||
if not blank_row:
|
||||
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()
|
||||
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", [])
|
||||
total = 0
|
||||
for d in self.get_values():
|
||||
total += flt(d.outstanding_amount, self.precision("credit", "accounts"))
|
||||
jd1 = self.append("accounts", {})
|
||||
jd1.account = d.account
|
||||
jd1.party = d.party
|
||||
for invoice in self.get_values():
|
||||
total += flt(invoice.outstanding_amount, self.precision("credit", "accounts"))
|
||||
self._append_outstanding_invoice_row(invoice)
|
||||
|
||||
if self.write_off_based_on == "Accounts Receivable":
|
||||
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", {})
|
||||
balancing_row = self.append("accounts", {})
|
||||
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":
|
||||
jd2.credit_in_account_currency = total
|
||||
balancing_row.credit_in_account_currency = total
|
||||
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
def get_values(self):
|
||||
cond = (
|
||||
f" and outstanding_amount <= {flt(self.write_off_amount)}"
|
||||
if flt(self.write_off_amount) > 0
|
||||
else ""
|
||||
)
|
||||
def _append_outstanding_invoice_row(self, invoice) -> None:
|
||||
"""Append a party row for a single outstanding invoice per the write-off basis."""
|
||||
row = self.append("accounts", {})
|
||||
row.account = invoice.account
|
||||
row.party = invoice.party
|
||||
|
||||
if self.write_off_based_on == "Accounts Receivable":
|
||||
return frappe.db.sql(
|
||||
"""select name, debit_to as account, customer as party, outstanding_amount
|
||||
from `tabSales Invoice` where docstatus = 1 and company = {}
|
||||
and outstanding_amount > 0 {}""".format("%s", cond),
|
||||
self.company,
|
||||
as_dict=True,
|
||||
row.party_type = "Customer"
|
||||
row.credit_in_account_currency = flt(
|
||||
invoice.outstanding_amount, self.precision("credit", "accounts")
|
||||
)
|
||||
row.reference_type = "Sales Invoice"
|
||||
row.reference_name = cstr(invoice.name)
|
||||
elif self.write_off_based_on == "Accounts Payable":
|
||||
return frappe.db.sql(
|
||||
"""select name, credit_to as account, supplier as party, outstanding_amount
|
||||
from `tabPurchase Invoice` where docstatus = 1 and company = {}
|
||||
and outstanding_amount > 0 {}""".format("%s", cond),
|
||||
self.company,
|
||||
as_dict=True,
|
||||
row.party_type = "Supplier"
|
||||
row.debit_in_account_currency = flt(
|
||||
invoice.outstanding_amount, self.precision("debit", "accounts")
|
||||
)
|
||||
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):
|
||||
if self.stock_entry:
|
||||
@@ -962,7 +1014,7 @@ def get_default_bank_cash_account(
|
||||
account: str | None = None,
|
||||
*,
|
||||
fetch_balance: bool = True,
|
||||
):
|
||||
) -> dict:
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
|
||||
|
||||
if mode_of_payment:
|
||||
@@ -1017,7 +1069,8 @@ def get_against_jv(
|
||||
start: int,
|
||||
page_len: int,
|
||||
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):
|
||||
return []
|
||||
|
||||
@@ -1048,67 +1101,97 @@ def get_against_jv(
|
||||
|
||||
|
||||
@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"):
|
||||
frappe.msgprint(_("No Permission"), raise_exception=1)
|
||||
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
if legacy_payload := kwargs.get("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"))
|
||||
due_date = None
|
||||
if doctype == "Journal Entry":
|
||||
return _get_journal_entry_outstanding(docname, account, party)
|
||||
|
||||
if args.get("doctype") == "Journal Entry":
|
||||
condition = " and party=%(party)s" if args.get("party") else ""
|
||||
if doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
return _get_invoice_outstanding(doctype, docname, company, account_currency)
|
||||
|
||||
against_jv_amount = frappe.db.sql(
|
||||
f"""
|
||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
||||
from `tabJournal Entry Account` where parent=%(docname)s and account=%(account)s {condition}
|
||||
and (reference_type is null or reference_type = '')""",
|
||||
args,
|
||||
|
||||
def _get_journal_entry_outstanding(docname: str, account: str | None, party: str | None) -> dict:
|
||||
"""Unreferenced debit-minus-credit balance for an account on a Journal Entry."""
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
query = (
|
||||
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
|
||||
amount_field = "credit_in_account_currency" if against_jv_amount > 0 else "debit_in_account_currency"
|
||||
return {amount_field: abs(against_jv_amount)}
|
||||
elif args.get("doctype") in ("Sales Invoice", "Purchase Invoice"):
|
||||
party_type = "Customer" if args.get("doctype") == "Sales Invoice" else "Supplier"
|
||||
invoice = frappe.db.get_value(
|
||||
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,
|
||||
}
|
||||
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": invoice.get("due_date"),
|
||||
}
|
||||
|
||||
|
||||
@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"):
|
||||
frappe.msgprint(_("No Permission"), raise_exception=1)
|
||||
|
||||
@@ -1128,7 +1211,7 @@ def get_account_details_and_party_type(
|
||||
debit: float | str | None = None,
|
||||
credit: 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."""
|
||||
if not frappe.has_permission("Account"):
|
||||
frappe.msgprint(_("No Permission"), raise_exception=1)
|
||||
@@ -1186,7 +1269,8 @@ def get_exchange_rate(
|
||||
debit: float | str | None = None,
|
||||
credit: float | str | 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
|
||||
if isinstance(exchange_rate, str):
|
||||
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
|
||||
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
|
||||
|
||||
@@ -24,7 +24,8 @@ def get_payment_entry_against_order(
|
||||
debit_in_account_currency: str | float | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
) -> dict | Document:
|
||||
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
@@ -74,7 +75,8 @@ def get_payment_entry_against_invoice(
|
||||
debit_in_account_currency: str | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
) -> dict | Document:
|
||||
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
@@ -110,32 +112,54 @@ def get_payment_entry_against_invoice(
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||
get_default_bank_cash_account,
|
||||
get_exchange_rate,
|
||||
)
|
||||
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
|
||||
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
|
||||
|
||||
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
|
||||
dict (for client calls).
|
||||
"""
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
exchange_rate = 1
|
||||
if args.get("party_account"):
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date in the reference document
|
||||
exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
exchange_rate = _reference_exchange_rate(ref_doc, args)
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||
|
||||
party_row = je.append(
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
|
||||
|
||||
def _reference_exchange_rate(ref_doc, args: dict) -> float:
|
||||
"""Exchange rate of the party account on the reference document's posting date."""
|
||||
if not args.get("party_account"):
|
||||
return 1
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
|
||||
|
||||
return get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
|
||||
|
||||
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||
"""Append the party (debtor/creditor) row that records the advance/payment."""
|
||||
return je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
@@ -153,14 +177,19 @@ def get_payment_entry(ref_doc, args):
|
||||
},
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
|
||||
# Make it bank_details
|
||||
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||
get_default_bank_cash_account,
|
||||
get_exchange_rate,
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date of the reference date
|
||||
# posting date assumed to be the reference document's posting/transaction date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
@@ -171,26 +200,17 @@ def get_payment_entry(ref_doc, args):
|
||||
bank_row.cost_center = cost_center
|
||||
|
||||
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||
|
||||
if bank_row.account_currency == args.get("party_account_currency"):
|
||||
bank_row.set(args.get("amount_field_bank"), amount)
|
||||
else:
|
||||
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||
|
||||
# Multi currency check again
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
return bank_row
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
|
||||
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
@@ -200,7 +220,8 @@ def make_inter_company_journal_entry(name: str, voucher_type: str, company: str)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
|
||||
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
@@ -211,7 +232,7 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | Document | No
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target):
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
|
||||
@@ -20,10 +20,11 @@ class AssetService:
|
||||
Journal Entries tied to asset scrapping or value adjustments.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self):
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
|
||||
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
@@ -34,7 +35,8 @@ class AssetService:
|
||||
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||
|
||||
def has_asset_adjustment_entry(self):
|
||||
def has_asset_adjustment_entry(self) -> None:
|
||||
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
|
||||
if self.doc.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
@@ -48,11 +50,13 @@ class AssetService:
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def update_asset_value(self):
|
||||
def update_asset_value(self) -> None:
|
||||
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self):
|
||||
def update_asset_on_depreciation(self) -> None:
|
||||
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
@@ -73,7 +77,8 @@ class AssetService:
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount):
|
||||
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
|
||||
"""Subtract the depreciation amount from the asset's relevant finance book."""
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
@@ -86,7 +91,8 @@ class AssetService:
|
||||
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||
)
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
|
||||
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
@@ -96,7 +102,8 @@ class AssetService:
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||
|
||||
def update_asset_on_disposal(self):
|
||||
def update_asset_on_disposal(self) -> None:
|
||||
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
|
||||
if self.doc.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.doc.get("accounts"):
|
||||
@@ -117,62 +124,74 @@ class AssetService:
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def unlink_asset_reference(self):
|
||||
def unlink_asset_reference(self) -> None:
|
||||
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
self.doc.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
je_found = False
|
||||
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if je_found:
|
||||
break
|
||||
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.doc.name:
|
||||
s.db_set("journal_entry", None)
|
||||
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
|
||||
je_found = True
|
||||
break
|
||||
if not je_found:
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
if self._is_depreciation_asset_row(d):
|
||||
self._reverse_asset_depreciation(d)
|
||||
elif (
|
||||
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
|
||||
):
|
||||
journal_entry_for_scrap = frappe.db.get_value(
|
||||
"Asset", d.reference_name, "journal_entry_for_scrap"
|
||||
)
|
||||
self._block_scrap_journal_cancel(d)
|
||||
|
||||
if journal_entry_for_scrap == self.doc.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
def _is_depreciation_asset_row(self, d) -> bool:
|
||||
return bool(
|
||||
self.doc.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
def _reverse_asset_depreciation(self, d) -> None:
|
||||
"""Add the depreciation amount back to the asset and unlink its schedule row."""
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
|
||||
self._restore_finance_book_value(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
|
||||
"""Unlink this entry from the depreciation schedule and credit back its finance book.
|
||||
|
||||
Returns True if a matching scheduled depreciation was found.
|
||||
"""
|
||||
for fb_row in asset.get("finance_books"):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.doc.name:
|
||||
s.db_set("journal_entry", None)
|
||||
fb_row.value_after_depreciation += debit
|
||||
fb_row.db_update()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _restore_finance_book_value(self, asset, debit: float) -> None:
|
||||
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += debit
|
||||
fb_row.db_update()
|
||||
|
||||
def _block_scrap_journal_cancel(self, d) -> None:
|
||||
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
|
||||
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
|
||||
if journal_entry_for_scrap == self.doc.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self) -> None:
|
||||
"""Detach this entry from any Asset Value Adjustment that referenced it."""
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
|
||||
@@ -18,86 +18,88 @@ class JournalEntryGLComposer(BaseGLComposer):
|
||||
from the first foreign-currency row (mirroring the former build_gl_map).
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
doc = self.doc
|
||||
gl_map = []
|
||||
|
||||
company_currency = erpnext.get_company_currency(doc.company)
|
||||
doc.transaction_currency = company_currency
|
||||
doc.transaction_exchange_rate = 1
|
||||
if doc.multi_currency:
|
||||
for row in doc.get("accounts"):
|
||||
if row.account_currency != company_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
doc.transaction_currency = row.account_currency
|
||||
doc.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
def compose(self) -> list:
|
||||
"""Project the Journal Entry's non-zero account rows into GL dicts."""
|
||||
self._set_transaction_currency()
|
||||
advance_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
for d in doc.get("accounts"):
|
||||
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
|
||||
r = [d.user_remark, doc.remark]
|
||||
r = [x for x in r if x]
|
||||
remarks = "\n".join(r)
|
||||
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": doc.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": doc.transaction_currency,
|
||||
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": doc.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
|
||||
gl_map.append(
|
||||
self.get_gl_dict(
|
||||
row,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
gl_map = []
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
|
||||
return gl_map
|
||||
|
||||
def _set_transaction_currency(self) -> None:
|
||||
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
|
||||
doc = self.doc
|
||||
doc.transaction_currency = erpnext.get_company_currency(doc.company)
|
||||
doc.transaction_exchange_rate = 1
|
||||
if not doc.multi_currency:
|
||||
return
|
||||
|
||||
for row in doc.get("accounts"):
|
||||
if row.account_currency != doc.transaction_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
doc.transaction_currency = row.account_currency
|
||||
doc.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
def _gl_row(self, d, advance_doctypes: list) -> dict:
|
||||
"""Build the GL dict for a single account row."""
|
||||
doc = self.doc
|
||||
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
|
||||
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": doc.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": doc.transaction_currency,
|
||||
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": doc.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
|
||||
return row
|
||||
|
||||
@@ -29,10 +29,11 @@ class JournalEntryReferenceValidator:
|
||||
orders and invoices.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate(self):
|
||||
def validate(self) -> None:
|
||||
"""Validate every reference-bearing row, then the referenced orders and invoices."""
|
||||
self.doc.reference_totals = {}
|
||||
self.doc.reference_types = {}
|
||||
self.doc.reference_accounts = {}
|
||||
@@ -47,23 +48,24 @@ class JournalEntryReferenceValidator:
|
||||
self._validate_orders()
|
||||
self._validate_invoices()
|
||||
|
||||
def _normalize_reference_fields(self, row):
|
||||
def _normalize_reference_fields(self, row) -> None:
|
||||
if not row.reference_type:
|
||||
row.reference_name = None
|
||||
if not row.reference_name:
|
||||
row.reference_type = None
|
||||
|
||||
def _has_party_reference(self, row):
|
||||
def _has_party_reference(self, row) -> bool:
|
||||
return bool(
|
||||
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
|
||||
)
|
||||
|
||||
def _reference_amount_field(self, row):
|
||||
def _reference_amount_field(self, row) -> str:
|
||||
if row.reference_type in ("Sales Order", "Sales Invoice"):
|
||||
return "credit_in_account_currency"
|
||||
return "debit_in_account_currency"
|
||||
|
||||
def _validate_order_direction(self, row):
|
||||
def _validate_order_direction(self, row) -> None:
|
||||
"""An order can only be linked on the side that records an advance."""
|
||||
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
@@ -73,7 +75,8 @@ class JournalEntryReferenceValidator:
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
|
||||
def _register_reference(self, row):
|
||||
def _register_reference(self, row) -> None:
|
||||
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
|
||||
if row.reference_name not in self.doc.reference_totals:
|
||||
self.doc.reference_totals[row.reference_name] = 0.0
|
||||
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||
@@ -81,7 +84,8 @@ class JournalEntryReferenceValidator:
|
||||
self.doc.reference_types[row.reference_name] = row.reference_type
|
||||
self.doc.reference_accounts[row.reference_name] = row.account
|
||||
|
||||
def _validate_reference_party_and_account(self, row):
|
||||
def _validate_reference_party_and_account(self, row) -> None:
|
||||
"""Reject a missing reference, then check party/account against the linked document."""
|
||||
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
|
||||
against_voucher = frappe.db.get_value(
|
||||
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
|
||||
@@ -94,7 +98,7 @@ class JournalEntryReferenceValidator:
|
||||
elif row.reference_type in ("Sales Order", "Purchase Order"):
|
||||
self._validate_order_party(row, against_voucher)
|
||||
|
||||
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields):
|
||||
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
|
||||
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
|
||||
if self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
return
|
||||
@@ -105,7 +109,9 @@ class JournalEntryReferenceValidator:
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_invoice_party_account(self, row, against_voucher):
|
||||
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
|
||||
"""Expected (party_account, party) for an invoice row, honouring deferred booking
|
||||
and invoice-discounting accounts."""
|
||||
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
|
||||
debit_or_credit = "Debit" if row.debit else "Credit"
|
||||
party_account = get_deferred_booking_accounts(
|
||||
@@ -120,7 +126,7 @@ class JournalEntryReferenceValidator:
|
||||
party_account = against_voucher[1]
|
||||
return party_account, against_voucher[0]
|
||||
|
||||
def _validate_order_party(self, row, against_voucher):
|
||||
def _validate_order_party(self, row, against_voucher) -> None:
|
||||
if against_voucher != row.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
@@ -128,8 +134,8 @@ class JournalEntryReferenceValidator:
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_orders(self):
|
||||
"""Validate totals, closed and docstatus for orders"""
|
||||
def _validate_orders(self) -> None:
|
||||
"""Validate totals, closed and docstatus for referenced orders."""
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
account = self.doc.reference_accounts[reference_name]
|
||||
@@ -140,7 +146,7 @@ class JournalEntryReferenceValidator:
|
||||
self._validate_order_status(order, reference_type, reference_name)
|
||||
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
|
||||
|
||||
def _validate_order_status(self, order, reference_type, reference_name):
|
||||
def _validate_order_status(self, order, reference_type, reference_name) -> None:
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
if flt(order.per_billed) >= 100:
|
||||
@@ -148,7 +154,8 @@ class JournalEntryReferenceValidator:
|
||||
if cstr(order.status) == "Closed":
|
||||
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||
|
||||
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name):
|
||||
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
|
||||
"""The advance paid against an order cannot exceed its grand total."""
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.doc.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
@@ -167,8 +174,8 @@ class JournalEntryReferenceValidator:
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_invoices(self):
|
||||
"""Validate totals and docstatus for invoices"""
|
||||
def _validate_invoices(self) -> None:
|
||||
"""Validate totals and docstatus for referenced invoices."""
|
||||
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
@@ -178,7 +185,8 @@ class JournalEntryReferenceValidator:
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
|
||||
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name):
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
|
||||
"""Payment booked against an invoice cannot exceed its outstanding amount."""
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
|
||||
@@ -169,8 +169,11 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency",
|
||||
"credit",
|
||||
"credit_in_account_currency",
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
]
|
||||
|
||||
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
|
||||
self.expected_gle = [
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
@@ -179,6 +182,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency": 0,
|
||||
"credit": 5000,
|
||||
"credit_in_account_currency": 5000,
|
||||
"debit_in_transaction_currency": 0,
|
||||
"credit_in_transaction_currency": 100,
|
||||
},
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
@@ -187,6 +192,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency": 100,
|
||||
"credit": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit_in_transaction_currency": 100,
|
||||
"credit_in_transaction_currency": 0,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -203,6 +210,52 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.assertFalse(gle)
|
||||
|
||||
def test_multi_currency_transaction_currency_on_foreign_debit(self):
|
||||
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
|
||||
|
||||
Transaction currency is USD (the first foreign row); the INR debit row must be
|
||||
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
|
||||
"""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.multi_currency = 1
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 100,
|
||||
"exchange_rate": 50,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": 5000,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
jv.submit()
|
||||
|
||||
self.voucher_no = jv.name
|
||||
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
|
||||
self.expected_gle = [
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"debit_in_transaction_currency": 100,
|
||||
"credit_in_transaction_currency": 0,
|
||||
},
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
"debit_in_transaction_currency": 0,
|
||||
"credit_in_transaction_currency": 100,
|
||||
},
|
||||
]
|
||||
self.check_gl_entries()
|
||||
|
||||
def test_reverse_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
|
||||
@@ -688,6 +741,95 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
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 test_get_payment_entry_against_order_builds_advance_je(self):
|
||||
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
|
||||
|
||||
self.assertEqual(je.voucher_type, "Bank Entry")
|
||||
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
|
||||
self.assertTrue(party_rows)
|
||||
self.assertEqual(party_rows[0].reference_type, "Sales Order")
|
||||
self.assertEqual(party_rows[0].reference_name, sales_order.name)
|
||||
self.assertEqual(party_rows[0].is_advance, "Yes")
|
||||
|
||||
def test_make_inter_company_journal_entry_builds_linked_draft(self):
|
||||
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
|
||||
|
||||
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
|
||||
result = make_inter_company_journal_entry(
|
||||
source.name, "Inter Company Journal Entry", "_Test Company 1"
|
||||
)
|
||||
|
||||
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
|
||||
self.assertEqual(result.get("company"), "_Test Company 1")
|
||||
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
|
||||
|
||||
|
||||
def make_journal_entry(
|
||||
account1,
|
||||
|
||||
@@ -10,8 +10,7 @@ from frappe import ValidationError, _, qb, scrub, throw
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Tuple
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Abs, Count, NullIf
|
||||
from frappe.query_builder.functions import Count
|
||||
from frappe.utils import cint, comma_or, flt, getdate, nowdate
|
||||
from frappe.utils.data import comma_and, fmt_money, get_link_to_form
|
||||
from pypika.functions import Coalesce, Sum
|
||||
@@ -2065,18 +2064,22 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
||||
company_currency = frappe.get_cached_value("Company", args.get("company"), "default_currency")
|
||||
|
||||
# Get positive outstanding sales /purchase invoices
|
||||
condition = ""
|
||||
if args.get("voucher_type") and args.get("voucher_no"):
|
||||
condition = f" and voucher_type={frappe.db.escape(args['voucher_type'])} and voucher_no={frappe.db.escape(args['voucher_no'])}"
|
||||
common_filter.append(ple.voucher_type == args["voucher_type"])
|
||||
common_filter.append(ple.voucher_no == args["voucher_no"])
|
||||
|
||||
# Add cost center condition
|
||||
if args.get("cost_center"):
|
||||
condition += f" and cost_center={frappe.db.escape(args.get('cost_center'))}"
|
||||
accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center"))
|
||||
|
||||
# dynamic dimension filters
|
||||
active_dimensions = get_dimensions()[0]
|
||||
for dim in active_dimensions:
|
||||
if args.get(dim.fieldname):
|
||||
condition += f" and {dim.fieldname}={frappe.db.escape(args.get(dim.fieldname))}"
|
||||
accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname))
|
||||
|
||||
date_fields_dict = {
|
||||
@@ -2085,16 +2088,23 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
||||
}
|
||||
|
||||
for fieldname, date_fields in date_fields_dict.items():
|
||||
from_date = frappe.db.escape(str(args.get(date_fields[0]))) if args.get(date_fields[0]) else None
|
||||
to_date = frappe.db.escape(str(args.get(date_fields[1]))) if args.get(date_fields[1]) else None
|
||||
|
||||
if args.get(date_fields[0]) and args.get(date_fields[1]):
|
||||
condition += f" and {fieldname} between {from_date} and {to_date}"
|
||||
posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
|
||||
elif args.get(date_fields[0]):
|
||||
# if only from date is supplied
|
||||
condition += f" and {fieldname} >= {from_date}"
|
||||
posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0])))
|
||||
elif args.get(date_fields[1]):
|
||||
# if only to date is supplied
|
||||
condition += f" and {fieldname} <= {to_date}"
|
||||
posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1])))
|
||||
|
||||
if args.get("company"):
|
||||
condition += " and company = {}".format(frappe.db.escape(args.get("company")))
|
||||
common_filter.append(ple.company == args.get("company"))
|
||||
|
||||
outstanding_invoices = []
|
||||
@@ -2147,7 +2157,7 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
||||
args.get("party_account"),
|
||||
party_account_currency,
|
||||
company_currency,
|
||||
filters=args,
|
||||
condition=condition,
|
||||
)
|
||||
|
||||
# Get all SO / PO which are not fully billed or against which full advance not paid
|
||||
@@ -2305,6 +2315,13 @@ def get_orders_to_be_billed(
|
||||
if not voucher_type:
|
||||
return []
|
||||
|
||||
# dynamic dimension filters
|
||||
condition = ""
|
||||
active_dimensions = get_dimensions(True)[0]
|
||||
for dim in active_dimensions:
|
||||
if filters.get(dim.fieldname):
|
||||
condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}"
|
||||
|
||||
if party_account_currency == company_currency:
|
||||
grand_total_field = "base_grand_total"
|
||||
rounded_total_field = "base_rounded_total"
|
||||
@@ -2312,35 +2329,38 @@ def get_orders_to_be_billed(
|
||||
grand_total_field = "grand_total"
|
||||
rounded_total_field = "rounded_total"
|
||||
|
||||
filters = filters or {}
|
||||
order = qb.DocType(voucher_type)
|
||||
invoice_amount = Coalesce(NullIf(order[rounded_total_field], 0), order[grand_total_field])
|
||||
|
||||
orders_query = (
|
||||
qb.from_(order)
|
||||
.select(
|
||||
order.name.as_("voucher_no"),
|
||||
invoice_amount.as_("invoice_amount"),
|
||||
(invoice_amount - order.advance_paid).as_("outstanding_amount"),
|
||||
order.transaction_date.as_("posting_date"),
|
||||
)
|
||||
.where(order[scrub(party_type)] == party)
|
||||
.where(order.docstatus == 1)
|
||||
.where(order.company == company)
|
||||
.where(order.status != "Closed")
|
||||
.where(invoice_amount > order.advance_paid)
|
||||
.where(Abs(100 - order.per_billed) > 0.01)
|
||||
.orderby(order.transaction_date)
|
||||
.orderby(order.name)
|
||||
orders = frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
name as voucher_no,
|
||||
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
|
||||
(if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) - advance_paid) as outstanding_amount,
|
||||
transaction_date as posting_date
|
||||
from
|
||||
`tab{voucher_type}`
|
||||
where
|
||||
{party_type} = %s
|
||||
and docstatus = 1
|
||||
and company = %s
|
||||
and status != "Closed"
|
||||
and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid
|
||||
and abs(100 - per_billed) > 0.01
|
||||
{condition}
|
||||
order by
|
||||
transaction_date, name
|
||||
""".format(
|
||||
**{
|
||||
"rounded_total_field": rounded_total_field,
|
||||
"grand_total_field": grand_total_field,
|
||||
"voucher_type": voucher_type,
|
||||
"party_type": scrub(party_type),
|
||||
"condition": condition,
|
||||
}
|
||||
),
|
||||
(party, company),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
active_dimensions = get_dimensions(True)[0]
|
||||
for dim in active_dimensions:
|
||||
if filters.get(dim.fieldname):
|
||||
orders_query = orders_query.where(order[dim.fieldname] == filters.get(dim.fieldname))
|
||||
|
||||
orders = orders_query.run(as_dict=True)
|
||||
|
||||
order_list = []
|
||||
for d in orders:
|
||||
if (
|
||||
@@ -2370,12 +2390,15 @@ def get_negative_outstanding_invoices(
|
||||
party_account_currency,
|
||||
company_currency,
|
||||
cost_center=None,
|
||||
filters=None,
|
||||
condition=None,
|
||||
):
|
||||
if party_type not in ["Customer", "Supplier"]:
|
||||
return []
|
||||
voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
account = "debit_to" if voucher_type == "Sales Invoice" else "credit_to"
|
||||
supplier_condition = ""
|
||||
if voucher_type == "Purchase Invoice":
|
||||
supplier_condition = "and (release_date is null or release_date <= CURRENT_DATE)"
|
||||
if party_account_currency == company_currency:
|
||||
grand_total_field = "base_grand_total"
|
||||
rounded_total_field = "base_rounded_total"
|
||||
@@ -2383,64 +2406,39 @@ def get_negative_outstanding_invoices(
|
||||
grand_total_field = "grand_total"
|
||||
rounded_total_field = "rounded_total"
|
||||
|
||||
filters = filters or {}
|
||||
invoice = qb.DocType(voucher_type)
|
||||
invoice_amount = Coalesce(NullIf(invoice[rounded_total_field], 0), invoice[grand_total_field])
|
||||
|
||||
query = (
|
||||
qb.from_(invoice)
|
||||
.select(
|
||||
ConstantColumn(voucher_type).as_("voucher_type"),
|
||||
invoice.name.as_("voucher_no"),
|
||||
invoice[account].as_("account"),
|
||||
invoice_amount.as_("invoice_amount"),
|
||||
invoice.outstanding_amount,
|
||||
invoice.posting_date,
|
||||
invoice.due_date,
|
||||
invoice.conversion_rate.as_("exchange_rate"),
|
||||
)
|
||||
.where(invoice[scrub(party_type)] == party)
|
||||
.where(invoice[account] == party_account)
|
||||
.where(invoice.docstatus == 1)
|
||||
.where(invoice.outstanding_amount < 0)
|
||||
.orderby(invoice.posting_date)
|
||||
.orderby(invoice.name)
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select
|
||||
"{voucher_type}" as voucher_type, name as voucher_no, {account} as account,
|
||||
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
|
||||
outstanding_amount, posting_date,
|
||||
due_date, conversion_rate as exchange_rate
|
||||
from
|
||||
`tab{voucher_type}`
|
||||
where
|
||||
{party_type} = %s and {party_account} = %s and docstatus = 1 and
|
||||
outstanding_amount < 0
|
||||
{supplier_condition}
|
||||
{condition}
|
||||
order by
|
||||
posting_date, name
|
||||
""".format(
|
||||
**{
|
||||
"supplier_condition": supplier_condition,
|
||||
"condition": condition,
|
||||
"rounded_total_field": rounded_total_field,
|
||||
"grand_total_field": grand_total_field,
|
||||
"voucher_type": voucher_type,
|
||||
"party_type": scrub(party_type),
|
||||
"party_account": "debit_to" if party_type == "Customer" else "credit_to",
|
||||
"cost_center": cost_center,
|
||||
"account": account,
|
||||
}
|
||||
),
|
||||
(party, party_account),
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if voucher_type == "Purchase Invoice":
|
||||
query = query.where(invoice.release_date.isnull() | (invoice.release_date <= date.today()))
|
||||
|
||||
if filters.get("voucher_type") and filters.get("voucher_no"):
|
||||
if filters["voucher_type"] != voucher_type:
|
||||
return []
|
||||
query = query.where(invoice.name == filters["voucher_no"])
|
||||
|
||||
if filters.get("cost_center"):
|
||||
query = query.where(invoice.cost_center == filters.get("cost_center"))
|
||||
|
||||
active_dimensions = get_dimensions()[0]
|
||||
for dim in active_dimensions:
|
||||
if filters.get(dim.fieldname):
|
||||
query = query.where(invoice[dim.fieldname] == filters.get(dim.fieldname))
|
||||
|
||||
date_fields_dict = {
|
||||
"posting_date": ["from_posting_date", "to_posting_date"],
|
||||
"due_date": ["from_due_date", "to_due_date"],
|
||||
}
|
||||
|
||||
for fieldname, date_fields in date_fields_dict.items():
|
||||
if filters.get(date_fields[0]) and filters.get(date_fields[1]):
|
||||
query = query.where(invoice[fieldname][filters.get(date_fields[0]) : filters.get(date_fields[1])])
|
||||
elif filters.get(date_fields[0]):
|
||||
query = query.where(invoice[fieldname] >= filters.get(date_fields[0]))
|
||||
elif filters.get(date_fields[1]):
|
||||
query = query.where(invoice[fieldname] <= filters.get(date_fields[1]))
|
||||
|
||||
if filters.get("company"):
|
||||
query = query.where(invoice.company == filters.get("company"))
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_party_details(company: str, party_type: str, party: str, date: str, cost_center: str | None = None):
|
||||
|
||||
@@ -425,7 +425,12 @@ class SellingController(StockController):
|
||||
row.new_item_code
|
||||
for row in frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"new_item_code": ("in", items_to_fetch), "is_active": 1, "docstatus": 1},
|
||||
filters={
|
||||
"new_item_code": ("in", items_to_fetch),
|
||||
"is_active": 1,
|
||||
"docstatus": 1,
|
||||
"disabled": 0,
|
||||
},
|
||||
fields="new_item_code",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -607,6 +607,9 @@ erpnext.buying.get_items_from_product_bundle = function (frm) {
|
||||
fieldname: "product_bundle",
|
||||
options: "Product Bundle",
|
||||
reqd: 1,
|
||||
get_query: () => {
|
||||
return { filters: { docstatus: 1, disabled: 0 } };
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "Currency",
|
||||
@@ -625,7 +628,7 @@ erpnext.buying.get_items_from_product_bundle = function (frm) {
|
||||
method: "erpnext.stock.doctype.packed_item.packed_item.get_items_from_product_bundle",
|
||||
args: {
|
||||
row: {
|
||||
item_code: args.product_bundle,
|
||||
product_bundle: args.product_bundle,
|
||||
quantity: args.quantity,
|
||||
parenttype: frm.doc.doctype,
|
||||
parent: frm.doc.name,
|
||||
|
||||
@@ -201,7 +201,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
}
|
||||
|
||||
if (this.frm.fields_dict["items"].grid.get_field("product_bundle")) {
|
||||
// restrict the version picker to submitted Product Bundles of the row's item
|
||||
// restrict the version picker to enabled, submitted Product Bundles of the row's item
|
||||
this.frm.set_query("product_bundle", "items", function (doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
|
||||
@@ -209,6 +209,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
filters: {
|
||||
new_item_code: row.item_code,
|
||||
docstatus: 1,
|
||||
disabled: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,13 +76,14 @@
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
"depends_on": "disabled",
|
||||
"description": "Deprecated: use Cancel / Is Active instead. Retained for backward compatibility.",
|
||||
"description": "A disabled Product Bundle cannot be selected in transactions.",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Disabled",
|
||||
"read_only": 1
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
@@ -102,7 +103,7 @@
|
||||
"idx": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-08 00:00:00.000000",
|
||||
"modified": "2026-06-10 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Product Bundle",
|
||||
|
||||
@@ -62,8 +62,10 @@ class ProductBundle(Document):
|
||||
self.db_set("is_active", 0)
|
||||
|
||||
def on_update_after_submit(self):
|
||||
# `is_active` is the only field editable after submit; keep a single active
|
||||
# version per parent item in sync when the user (re)activates a version.
|
||||
# `is_active` and `disabled` are the only fields editable after submit; keep a
|
||||
# single active version per parent item in sync when the user (re)activates a
|
||||
# version. `disabled` is orthogonal: it parks a version without ceding the
|
||||
# active slot, so re-enabling restores it without re-activation.
|
||||
if self.is_active:
|
||||
self.make_active()
|
||||
|
||||
@@ -171,17 +173,19 @@ def get_next_version_index(existing_names: list[str]) -> int:
|
||||
|
||||
|
||||
def get_active_product_bundle(item_code: str) -> str | None:
|
||||
"""Return the name of the active, submitted Product Bundle for ``item_code``, else None.
|
||||
"""Return the name of the active, enabled, submitted Product Bundle for
|
||||
``item_code``, else None.
|
||||
|
||||
This is the single resolution entry point for every consumer of bundles; it
|
||||
replaces the legacy ``exists("Product Bundle", {name/new_item_code, disabled: 0})``
|
||||
lookups that assumed one mutable bundle per item.
|
||||
lookups that assumed one mutable bundle per item. A disabled bundle resolves to
|
||||
None even if it still holds the active slot for its parent item.
|
||||
"""
|
||||
if not item_code:
|
||||
return None
|
||||
return frappe.db.get_value(
|
||||
"Product Bundle",
|
||||
{"new_item_code": item_code, "is_active": 1, "docstatus": 1},
|
||||
{"new_item_code": item_code, "is_active": 1, "docstatus": 1, "disabled": 0},
|
||||
"name",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
frappe.listview_settings["Product Bundle"] = {
|
||||
add_fields: ["is_active", "disabled"],
|
||||
get_indicator(doc) {
|
||||
// Draft and Cancelled fall through to the standard docstatus indicators;
|
||||
// this only refines submitted bundles.
|
||||
if (doc.disabled) {
|
||||
return [__("Disabled"), "grey", "disabled,=,1"];
|
||||
}
|
||||
if (doc.docstatus === 1 && doc.is_active) {
|
||||
return [__("Active"), "green", "is_active,=,1|disabled,=,0|docstatus,=,1"];
|
||||
}
|
||||
// inactive submitted versions keep the default "Submitted" indicator
|
||||
},
|
||||
};
|
||||
@@ -103,6 +103,38 @@ class TestProductBundle(ERPNextTestSuite):
|
||||
bundle.items[0].qty = 99
|
||||
self.assertRaises(frappe.exceptions.UpdateAfterSubmitError, bundle.save)
|
||||
|
||||
def test_disabled_bundle_is_not_resolved(self):
|
||||
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
|
||||
bundle.disabled = 1
|
||||
bundle.save()
|
||||
self.assertIsNone(get_active_product_bundle(self.parent))
|
||||
|
||||
# disabling parks the version without ceding the active slot, so re-enabling
|
||||
# restores resolution without re-activation
|
||||
self.assertEqual(frappe.db.get_value("Product Bundle", bundle.name, "is_active"), 1)
|
||||
bundle.disabled = 0
|
||||
bundle.save()
|
||||
self.assertEqual(get_active_product_bundle(self.parent), bundle.name)
|
||||
|
||||
def test_item_where_used_report_shows_disabled_flag(self):
|
||||
from erpnext.stock.report.item_where_used.item_where_used import execute
|
||||
|
||||
bundle = make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
bundle.disabled = 1
|
||||
bundle.save()
|
||||
|
||||
_, component_rows = execute({"item": "_Test PB Child A", "section": "Where Used"})
|
||||
rows = [r for r in component_rows if r.document_name == bundle.name]
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(rows[0].disabled, 1)
|
||||
self.assertEqual(rows[0].is_active, 1)
|
||||
|
||||
_, parent_rows = execute({"item": self.parent, "section": "References"})
|
||||
rows = [r for r in parent_rows if r.document_name == bundle.name]
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(rows[0].disabled, 1)
|
||||
|
||||
def test_child_cannot_be_active_bundle(self):
|
||||
make_product_bundle(self.parent, ["_Test PB Child A"])
|
||||
outer = make_item("_Test PB Outer", {"is_stock_item": 0, "is_sales_item": 1}).name
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
|
||||
import frappe
|
||||
import frappe.defaults
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt
|
||||
|
||||
@@ -174,30 +175,6 @@ def reset_packing_list(doc):
|
||||
return reset_table
|
||||
|
||||
|
||||
def get_product_bundle_items(item_code):
|
||||
product_bundle = frappe.qb.DocType("Product Bundle")
|
||||
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(product_bundle_item)
|
||||
.join(product_bundle)
|
||||
.on(product_bundle_item.parent == product_bundle.name)
|
||||
.select(
|
||||
product_bundle_item.item_code,
|
||||
product_bundle_item.qty,
|
||||
product_bundle_item.uom,
|
||||
product_bundle_item.description,
|
||||
)
|
||||
.where(
|
||||
(product_bundle.new_item_code == item_code)
|
||||
& (product_bundle.is_active == 1)
|
||||
& (product_bundle.docstatus == 1)
|
||||
)
|
||||
.orderby(product_bundle_item.idx)
|
||||
)
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_product_bundle_items_by_name(bundle_name):
|
||||
"Component rows of a specific Product Bundle version."
|
||||
product_bundle_item = frappe.qb.DocType("Product Bundle Item")
|
||||
@@ -219,14 +196,25 @@ def get_bundle_version_for_row(item_row):
|
||||
|
||||
Honours a version explicitly chosen on the row (validated to be a submitted
|
||||
bundle of that item); otherwise falls back to the item's active version. A stale
|
||||
choice (e.g. left over after changing the item) self-heals back to the active one.
|
||||
choice (e.g. left over after changing the item) self-heals back to the active
|
||||
one, but a disabled choice blocks the transaction instead of silently switching
|
||||
versions behind the user's back.
|
||||
"""
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
chosen = item_row.get("product_bundle") if item_row.meta.has_field("product_bundle") else None
|
||||
if chosen:
|
||||
bundle = frappe.db.get_value("Product Bundle", chosen, ["new_item_code", "docstatus"], as_dict=True)
|
||||
bundle = frappe.db.get_value(
|
||||
"Product Bundle", chosen, ["new_item_code", "docstatus", "disabled"], as_dict=True
|
||||
)
|
||||
if bundle and bundle.new_item_code == item_row.item_code and bundle.docstatus == 1:
|
||||
if bundle.disabled:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Product Bundle {1} is disabled and cannot be used in transactions.").format(
|
||||
item_row.idx, frappe.bold(chosen)
|
||||
),
|
||||
title=_("Disabled Product Bundle"),
|
||||
)
|
||||
return chosen
|
||||
|
||||
return get_active_product_bundle(item_row.item_code)
|
||||
@@ -445,9 +433,31 @@ def on_doctype_update():
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_items_from_product_bundle(row: str):
|
||||
"""Item details for each component of a Product Bundle.
|
||||
|
||||
``row.product_bundle`` selects a specific version by document name (the buying
|
||||
dialog passes this); ``row.item_code`` is the legacy contract, resolving the
|
||||
parent item's active version.
|
||||
"""
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
row, items = ItemDetailsCtx(json.loads(row)), []
|
||||
|
||||
bundled_items = get_product_bundle_items(row["item_code"])
|
||||
if bundle_name := row.get("product_bundle"):
|
||||
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)
|
||||
bundle = frappe.db.get_value("Product Bundle", bundle_name, ["docstatus", "disabled"], as_dict=True)
|
||||
if not bundle or bundle.docstatus != 1:
|
||||
frappe.throw(_("Product Bundle {0} is not submitted").format(frappe.bold(bundle_name)))
|
||||
if bundle.disabled:
|
||||
frappe.throw(
|
||||
_("Product Bundle {0} is disabled and cannot be used in transactions.").format(
|
||||
frappe.bold(bundle_name)
|
||||
)
|
||||
)
|
||||
elif bundle_name := get_active_product_bundle(row.get("item_code")):
|
||||
frappe.has_permission("Product Bundle", "read", bundle_name, throw=True)
|
||||
|
||||
bundled_items = get_product_bundle_items_by_name(bundle_name) if bundle_name else []
|
||||
for item in bundled_items:
|
||||
row.update(
|
||||
{
|
||||
|
||||
@@ -165,6 +165,80 @@ class TestPackedItem(ERPNextTestSuite):
|
||||
self.assertEqual(so.items[0].product_bundle, v1)
|
||||
self.assertEqual(sorted(pi.item_code for pi in so.packed_items), sorted(self.bundle_items))
|
||||
|
||||
def test_disabled_bundle_blocks_transaction(self):
|
||||
"A row that explicitly references a disabled version cannot be saved."
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
version = get_active_product_bundle(self.bundle)
|
||||
so = make_sales_order(item_code=self.bundle, qty=1, warehouse=self.warehouse, do_not_submit=True)
|
||||
self.assertEqual(so.items[0].product_bundle, version)
|
||||
|
||||
frappe.db.set_value("Product Bundle", version, "disabled", 1)
|
||||
self.assertRaises(frappe.ValidationError, so.save)
|
||||
|
||||
def test_disabled_bundle_is_not_packed(self):
|
||||
"Without an explicit version, a disabled bundle is not treated as a bundle at all."
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
|
||||
version = get_active_product_bundle(self.bundle2)
|
||||
frappe.db.set_value("Product Bundle", version, "disabled", 1)
|
||||
|
||||
so = make_sales_order(item_code=self.bundle2, qty=1, warehouse=self.warehouse, do_not_submit=True)
|
||||
self.assertEqual(so.items[0].is_product_bundle, 0)
|
||||
self.assertFalse(so.items[0].product_bundle)
|
||||
self.assertFalse(so.get("packed_items"))
|
||||
|
||||
def test_get_items_from_product_bundle_endpoint(self):
|
||||
"The buying dialog passes the chosen version by document name (legacy: parent item code)."
|
||||
import json
|
||||
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
from erpnext.stock.doctype.packed_item.packed_item import get_items_from_product_bundle
|
||||
|
||||
ctx = {
|
||||
"quantity": 2,
|
||||
"doctype": "Purchase Order",
|
||||
"parenttype": "Purchase Order",
|
||||
"company": "_Test Company",
|
||||
"currency": "INR",
|
||||
"conversion_rate": 1,
|
||||
"transaction_date": nowdate(),
|
||||
}
|
||||
|
||||
# by document name, as the buying dialog sends it (bundle names are PB-prefixed
|
||||
# since versioning, so they no longer double as the parent item code)
|
||||
version = get_active_product_bundle(self.bundle)
|
||||
items = get_items_from_product_bundle(json.dumps({"product_bundle": version, **ctx}))
|
||||
self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items))
|
||||
self.assertEqual([i.qty for i in items], [4, 4])
|
||||
|
||||
# legacy contract: the parent item code resolves to its active version
|
||||
items = get_items_from_product_bundle(json.dumps({"item_code": self.bundle, **ctx}))
|
||||
self.assertEqual(sorted(i.item_code for i in items), sorted(self.bundle_items))
|
||||
|
||||
# an unsubmitted version is rejected
|
||||
draft = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Product Bundle",
|
||||
"new_item_code": make_item(properties={"is_stock_item": 0}).name,
|
||||
"items": [{"item_code": self.bundle_items[0], "qty": 1}],
|
||||
}
|
||||
).insert()
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
get_items_from_product_bundle,
|
||||
json.dumps({"product_bundle": draft.name, **ctx}),
|
||||
)
|
||||
|
||||
# a disabled version is rejected
|
||||
frappe.db.set_value("Product Bundle", version, "disabled", 1)
|
||||
self.addCleanup(frappe.db.set_value, "Product Bundle", version, "disabled", 0)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
get_items_from_product_bundle,
|
||||
json.dumps({"product_bundle": version, **ctx}),
|
||||
)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
|
||||
def test_recurring_bundle_item(self):
|
||||
"Test impact on packed items if same bundle item is added and removed."
|
||||
|
||||
@@ -139,7 +139,9 @@ def get_items(filters):
|
||||
item.brand,
|
||||
item.stock_uom,
|
||||
)
|
||||
.where((IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1))
|
||||
.where(
|
||||
(IfNull(item.disabled, 0) == 0) & (pb.is_active == 1) & (pb.docstatus == 1) & (pb.disabled == 0)
|
||||
)
|
||||
)
|
||||
|
||||
if item_code := filters.get("item_code"):
|
||||
@@ -181,7 +183,12 @@ def get_items(filters):
|
||||
pbi.uom,
|
||||
pbi.qty,
|
||||
)
|
||||
.where(pb.new_item_code.isin(parent_items) & (pb.is_active == 1) & (pb.docstatus == 1))
|
||||
.where(
|
||||
pb.new_item_code.isin(parent_items)
|
||||
& (pb.is_active == 1)
|
||||
& (pb.docstatus == 1)
|
||||
& (pb.disabled == 0)
|
||||
)
|
||||
).run(as_dict=1)
|
||||
|
||||
child_items = set()
|
||||
|
||||
@@ -308,6 +308,11 @@ class FIFOSlots:
|
||||
# prepare single sle voucher detail lookup
|
||||
self.prepare_stock_reco_voucher_wise_count()
|
||||
|
||||
if stock_ledger_entries is None:
|
||||
# nested queries invalidate the streaming cursor below,
|
||||
# so batchwise valuation flags must be resolved beforehand
|
||||
self._prefetch_batchwise_valuations()
|
||||
|
||||
with frappe.db.unbuffered_cursor():
|
||||
if stock_ledger_entries is None:
|
||||
stock_ledger_entries = self._get_stock_ledger_entries()
|
||||
@@ -425,12 +430,38 @@ class FIFOSlots:
|
||||
|
||||
def _get_batchwise_valuation(self, batch_no: str):
|
||||
if batch_no not in self.batchwise_valuation_by_batch:
|
||||
# only reachable when stock ledger entries are passed in directly;
|
||||
# the streaming path prefetches all flags before iteration
|
||||
self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value(
|
||||
"Batch", batch_no, "use_batchwise_valuation"
|
||||
)
|
||||
|
||||
return self.batchwise_valuation_by_batch[batch_no]
|
||||
|
||||
def _prefetch_batchwise_valuations(self) -> None:
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
batch = frappe.qb.DocType("Batch")
|
||||
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.left_join(batch)
|
||||
.on(sle.batch_no == batch.name)
|
||||
.select(sle.batch_no, batch.use_batchwise_valuation)
|
||||
.distinct()
|
||||
.where(
|
||||
(sle.batch_no.isnotnull())
|
||||
& (sle.company == self.filters.get("company"))
|
||||
& (sle.posting_datetime <= to_date)
|
||||
& (sle.is_cancelled != 1)
|
||||
)
|
||||
)
|
||||
|
||||
query = self._apply_filter(query, sle, "item_code")
|
||||
|
||||
for batch_no, use_batchwise_valuation in query.run():
|
||||
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
|
||||
|
||||
def _init_key_stores(self, row: dict) -> tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
|
||||
@@ -1434,6 +1434,80 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
|
||||
)
|
||||
|
||||
def test_legacy_batch_no_sle_with_streaming_cursor(self):
|
||||
"""SLEs carrying the legacy batch_no field must not trigger nested
|
||||
queries while entries stream through an unbuffered cursor."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
suffix = frappe.generate_hash(length=8).upper()
|
||||
item_code = make_item(
|
||||
f"Test Stock Ageing Legacy Batch {suffix}",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": f"SA-LEG-{suffix}-.###",
|
||||
"valuation_method": "FIFO",
|
||||
},
|
||||
).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
base_date = nowdate()
|
||||
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=10,
|
||||
rate=10,
|
||||
posting_date=add_days(base_date, -2),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
create_stock_reconciliation(
|
||||
item_code=item_code,
|
||||
warehouse=warehouse,
|
||||
qty=5,
|
||||
rate=10,
|
||||
batch_no=batch_no,
|
||||
posting_date=add_days(base_date, -1),
|
||||
posting_time="10:00:00",
|
||||
)
|
||||
|
||||
# mimic pre-bundle data where SLEs carry batch_no directly
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
{"item_code": item_code},
|
||||
"batch_no",
|
||||
batch_no,
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date=base_date,
|
||||
ranges=["30", "60", "90"],
|
||||
item_code=item_code,
|
||||
)
|
||||
fifo_slots = FIFOSlots(filters)
|
||||
|
||||
# fetch row by row so the streaming result set is still active
|
||||
# while each stock ledger entry is processed
|
||||
with patch("frappe.database.database.SQL_ITERATOR_BATCH_SIZE", 1):
|
||||
slots = fifo_slots.generate()
|
||||
|
||||
self.assertEqual(fifo_slots.batchwise_valuation_by_batch.get(batch_no), 1)
|
||||
self.assertEqual(slots[item_code]["total_qty"], 5.0)
|
||||
|
||||
|
||||
def generate_item_and_item_wh_wise_slots(filters, sle):
|
||||
"Return results with and without 'show_warehouse_wise_stock'"
|
||||
|
||||
Reference in New Issue
Block a user