mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-13 18:28:48 +00:00
Compare commits
1 Commits
develop
...
chore/budg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f9ad833d0 |
28
.github/POSTGRES_COMPATIBILITY.md
vendored
28
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -150,34 +150,6 @@ Add it only when it is functionally dependent on the existing select columns; ot
|
||||
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
|
||||
unchanged.
|
||||
|
||||
### 3.1 Second-order traps — when the `Max()`/`Min()` wrap itself is the bug
|
||||
|
||||
The wrap is only a no-op when the column is provably single-valued per group (**"`Max()` means
|
||||
provably constant"**). When the column can genuinely vary, the wrap is a decision, and a full
|
||||
audit of these fixes found four recurring mistakes:
|
||||
|
||||
- **Incoherent pair** — two semantically-coupled columns (a flag + a link:
|
||||
`is_phantom_item` + `bom_no`; a discriminator + its value) aggregated with *independent*
|
||||
`Max()`/`Min()` can pair values from **different rows** — a chimera row that never existed.
|
||||
MariaDB's loose pick was at least row-coherent. Fix: group by the pair (when consumers
|
||||
tolerate the extra rows), or select one **representative row** (`Min(child.name)` subquery +
|
||||
join-back) so every column comes from the same line.
|
||||
- **NULL-skipping** — `MAX`/`MIN` ignore NULLs, so `Max()` over a mostly-NULL discriminator
|
||||
(an `original_item`-style column) *deterministically* returns the non-NULL value where
|
||||
MariaDB could return NULL — deterministically wrong where the old behavior was only
|
||||
intermittently wrong. Flag it wherever "no value" is a meaningful state (fallback gates,
|
||||
dict keys).
|
||||
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
|
||||
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
|
||||
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
|
||||
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
|
||||
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
|
||||
average for a rate. A blind `Max` can understate urgency or overstate a figure.
|
||||
|
||||
Review heuristic: **if choosing between `Max` and `Min` would change the answer, the column is
|
||||
not functionally dependent** — wrapping either is the wrong fix. Group by it, restructure, or
|
||||
pick a bound for a stated reason, and cover the varying-group case with a test.
|
||||
|
||||
---
|
||||
|
||||
## 4. False positives — do NOT flag these
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,10 +37,6 @@
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
"account_type": "Stock",
|
||||
"account_category": "Stock Assets"
|
||||
},
|
||||
@@ -227,6 +223,10 @@
|
||||
"Stock Received But Not Billed": {
|
||||
"account_type": "Stock Received But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
},
|
||||
"Stock Delivered But Not Billed": {
|
||||
"account_type": "Stock Delivered But Not Billed",
|
||||
"account_category": "Trade Payables"
|
||||
}
|
||||
},
|
||||
"Duties and Taxes": {
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
"account_type": "Cash"
|
||||
},
|
||||
"Petty Cash Fund": {
|
||||
"account_number": "1110",
|
||||
"account_number": "1200",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Cash",
|
||||
"Petty Cash Fund": {
|
||||
"account_number": "1111",
|
||||
"account_number": "1201",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Cash"
|
||||
@@ -35,16 +35,10 @@
|
||||
}
|
||||
},
|
||||
"Bank Accounts": {
|
||||
"account_number": "1200",
|
||||
"account_number": "1102",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Bank",
|
||||
"Cash in Bank - Checking Account": {
|
||||
"account_number": "1201",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Bank"
|
||||
}
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Advances to Officers & Employees": {
|
||||
"account_number": "1290",
|
||||
@@ -110,20 +104,25 @@
|
||||
"account_number": "1511",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"Factory Overhead Variance": {
|
||||
"account_number": "1512",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Finished Goods": {
|
||||
"account_number": "1540",
|
||||
"account_number": "1520",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"Finished Goods Inventory": {
|
||||
"account_number": "1541",
|
||||
"account_number": "1531",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"Inventory in Transit": {
|
||||
"account_number": "1542",
|
||||
"account_number": "1532",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Stock Adjustment"
|
||||
@@ -269,7 +268,7 @@
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Intangible Assets": {
|
||||
"System Development": {
|
||||
"account_number": "1940",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
@@ -278,17 +277,6 @@
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Accumulated Amortization - Intangible Assets": {
|
||||
"account_number": "1950",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"Accum Amortization - System Development": {
|
||||
"account_number": "1951",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -418,7 +406,8 @@
|
||||
"Customer Deposits": {
|
||||
"account_number": "2500",
|
||||
"is_group": 0,
|
||||
"root_type": "Liability"
|
||||
"root_type": "Liability",
|
||||
"account_type": "Payable"
|
||||
}
|
||||
},
|
||||
"Non Current Liabilities": {
|
||||
@@ -574,28 +563,6 @@
|
||||
"is_group": 0,
|
||||
"root_type": "Income"
|
||||
}
|
||||
},
|
||||
"Exchange Gain": {
|
||||
"account_number": "6030",
|
||||
"is_group": 1,
|
||||
"root_type": "Income",
|
||||
"Exchange Gain - Detail": {
|
||||
"account_number": "6031",
|
||||
"is_group": 0,
|
||||
"root_type": "Income",
|
||||
"account_type": "Indirect Income"
|
||||
}
|
||||
},
|
||||
"Gain on Asset Disposal": {
|
||||
"account_number": "6040",
|
||||
"is_group": 1,
|
||||
"root_type": "Income",
|
||||
"Gain on Asset Disposal - Detail": {
|
||||
"account_number": "6041",
|
||||
"is_group": 0,
|
||||
"root_type": "Income",
|
||||
"account_type": "Indirect Income"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -608,7 +575,7 @@
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5002",
|
||||
"account_number": "5010",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
@@ -861,61 +828,20 @@
|
||||
"root_type": "Expense"
|
||||
}
|
||||
},
|
||||
"Other Expenses": {
|
||||
"account_number": "5200",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Bank Charges": {
|
||||
"account_number": "5201",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Interest Expenses Bank": {
|
||||
"account_number": "5202",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Write Off": {
|
||||
"account_number": "5203",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Exchange Loss": {
|
||||
"account_number": "5204",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Loss on Asset Disposal": {
|
||||
"account_number": "5205",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
}
|
||||
},
|
||||
"Provision For Income Tax": {
|
||||
"account_number": "5300",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"account_number": "5400",
|
||||
"account_number": "5200",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"Round Off": {
|
||||
"account_number": "5500",
|
||||
"account_number": "5300",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Round Off"
|
||||
},
|
||||
"Expenses Included In Valuation": {
|
||||
"account_number": "5600",
|
||||
"account_number": "5400",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
|
||||
@@ -1,59 +1,10 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
aggregate_with_last_account_closing_balance,
|
||||
generate_key,
|
||||
)
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def entry(**overrides):
|
||||
row = {"debit": 0, "credit": 0, "debit_in_account_currency": 0, "credit_in_account_currency": 0}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class TestAccountClosingBalance(ERPNextTestSuite):
|
||||
"""The closing-balance snapshot is built by merging this period's entries with the
|
||||
previous period's. These lock the merge/key logic that drives that carry-forward."""
|
||||
|
||||
def test_matching_entries_are_summed(self):
|
||||
# this is how a prior-period balance carries forward into the current one
|
||||
merged = aggregate_with_last_account_closing_balance(
|
||||
[
|
||||
entry(account="Cash - _TC", debit=100, debit_in_account_currency=100),
|
||||
entry(
|
||||
account="Cash - _TC",
|
||||
debit=50,
|
||||
credit=20,
|
||||
debit_in_account_currency=50,
|
||||
credit_in_account_currency=20,
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(merged), 1)
|
||||
row = next(iter(merged.values()))
|
||||
self.assertEqual(row["debit"], 150)
|
||||
self.assertEqual(row["credit"], 20)
|
||||
# the account-currency columns are accumulated in the same pass
|
||||
self.assertEqual(row["debit_in_account_currency"], 150)
|
||||
self.assertEqual(row["credit_in_account_currency"], 20)
|
||||
|
||||
def test_entries_are_kept_separate_per_dimension(self):
|
||||
merged = aggregate_with_last_account_closing_balance(
|
||||
[
|
||||
entry(account="Cash - _TC", cost_center="CC1", debit=100, debit_in_account_currency=100),
|
||||
entry(account="Cash - _TC", cost_center="CC2", debit=40, debit_in_account_currency=40),
|
||||
],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(merged), 2)
|
||||
|
||||
def test_period_closing_flag_is_part_of_the_key(self):
|
||||
# a P&L reversal (flag 0) and a closing-account entry (flag 1) for the same
|
||||
# account must not merge, so the flag has to distinguish their keys
|
||||
key_reversal, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=0), [])
|
||||
key_closing, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=1), [])
|
||||
self.assertNotEqual(key_reversal, key_closing)
|
||||
pass
|
||||
|
||||
@@ -359,13 +359,3 @@ def create_accounting_dimensions_for_doctype(doctype):
|
||||
create_custom_field(doctype, df, ignore_validate=True)
|
||||
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
|
||||
|
||||
def get_dimension_fieldname(dim_doctype: str) -> str:
|
||||
"""
|
||||
Return the `GL Entry` fieldname for a given dimension.
|
||||
"""
|
||||
if dim_doctype in ("Cost Center", "Project"):
|
||||
return frappe.scrub(dim_doctype)
|
||||
|
||||
return frappe.db.get_value("Accounting Dimension", {"document_type": dim_doctype}, "fieldname")
|
||||
|
||||
@@ -6,7 +6,7 @@ frappe.ui.form.on("Accounting Dimension Filter", {
|
||||
let help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<p>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
|
||||
</p>
|
||||
</td></tr>
|
||||
|
||||
@@ -107,7 +107,7 @@ def get_party_bank_account(party_type, party):
|
||||
)
|
||||
|
||||
|
||||
def get_default_company_bank_account(company, party_type, party, ignore_permissions=True):
|
||||
def get_default_company_bank_account(company, party_type, party):
|
||||
default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account")
|
||||
if default_company_bank_account:
|
||||
if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"):
|
||||
@@ -118,14 +118,6 @@ def get_default_company_bank_account(company, party_type, party, ignore_permissi
|
||||
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
|
||||
)
|
||||
|
||||
if not ignore_permissions:
|
||||
default_company_bank_account = (
|
||||
default_company_bank_account
|
||||
if default_company_bank_account
|
||||
and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select")
|
||||
else None
|
||||
)
|
||||
|
||||
return default_company_bank_account
|
||||
|
||||
|
||||
@@ -196,7 +188,7 @@ def get_closing_balance_as_per_statement(bank_account: str, date: str):
|
||||
return {"balance": 0, "date": None}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
|
||||
"""
|
||||
Set the closing balance as per statement for a bank account and date
|
||||
|
||||
@@ -1,76 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.bank_guarantee.bank_guarantee import get_voucher_details
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
BANK = "_Test BG Bank"
|
||||
|
||||
|
||||
class TestBankGuarantee(ERPNextTestSuite):
|
||||
"""Bank Guarantee records a guarantee issued/received against a customer or
|
||||
supplier. validate() needs a party; on_submit() needs the bank details filled in."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
if not frappe.db.exists("Bank", BANK):
|
||||
frappe.get_doc({"doctype": "Bank", "bank_name": BANK}).insert()
|
||||
|
||||
def make_bg(self, **args):
|
||||
args = frappe._dict(args)
|
||||
doc = frappe.new_doc("Bank Guarantee")
|
||||
doc.bg_type = args.bg_type or "Receiving"
|
||||
doc.amount = args.amount if args.amount is not None else 1000
|
||||
doc.start_date = args.start_date or "2026-06-01"
|
||||
if args.end_date:
|
||||
doc.end_date = args.end_date
|
||||
doc.customer = args.get("customer", "_Test Customer")
|
||||
doc.supplier = args.get("supplier")
|
||||
# fields on_submit requires — present by default, cleared per-test to assert the guard
|
||||
doc.bank_guarantee_number = args.get("bank_guarantee_number", "BG-001")
|
||||
doc.name_of_beneficiary = args.get("name_of_beneficiary", "Test Beneficiary")
|
||||
doc.bank = args.get("bank", BANK)
|
||||
return doc
|
||||
|
||||
def test_validate_requires_customer_or_supplier(self):
|
||||
doc = self.make_bg(customer=None)
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_submit_requires_guarantee_number(self):
|
||||
doc = self.make_bg(bank_guarantee_number="")
|
||||
doc.insert()
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
|
||||
def test_submit_requires_beneficiary_name(self):
|
||||
doc = self.make_bg(name_of_beneficiary="")
|
||||
doc.insert()
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
|
||||
def test_submit_requires_bank(self):
|
||||
doc = self.make_bg(bank="")
|
||||
doc.insert()
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
|
||||
def test_valid_guarantee_submits(self):
|
||||
doc = self.make_bg()
|
||||
doc.insert()
|
||||
doc.submit()
|
||||
self.assertEqual(frappe.db.get_value("Bank Guarantee", doc.name, "docstatus"), 1)
|
||||
|
||||
def test_get_voucher_details_for_receiving(self):
|
||||
so = make_sales_order()
|
||||
details = get_voucher_details("Receiving", so.name)
|
||||
self.assertEqual(details.customer, so.customer)
|
||||
self.assertEqual(flt(details.grand_total), flt(so.grand_total))
|
||||
|
||||
def test_end_date_before_start_date_is_not_validated(self):
|
||||
# SUSPECTED BUG: validate() never checks that end_date >= start_date, so a
|
||||
# guarantee that expires before it starts saves cleanly. Locking the current
|
||||
# (wrong) behaviour so a future fix that adds the check trips this test.
|
||||
doc = self.make_bg(start_date="2026-06-30", end_date="2026-06-01")
|
||||
doc.insert()
|
||||
self.assertTrue(frappe.db.exists("Bank Guarantee", doc.name))
|
||||
pass
|
||||
|
||||
@@ -116,7 +116,7 @@ def get_account_balance(bank_account: str, till_date: str | date, company: str):
|
||||
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_bank_transaction(
|
||||
bank_transaction_name: str, reference_number: str, party_type: str | None = None, party: str | None = None
|
||||
):
|
||||
@@ -146,7 +146,7 @@ def update_bank_transaction(
|
||||
)[0]
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_journal_entry_bts(
|
||||
bank_transaction_name: str,
|
||||
reference_number: str | None = None,
|
||||
@@ -305,7 +305,7 @@ def create_journal_entry_bts(
|
||||
return reconcile_vouchers(bank_transaction_name, vouchers, is_new_voucher=True)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_payment_entry_bts(
|
||||
bank_transaction_name: str,
|
||||
reference_number: str | None = None,
|
||||
@@ -500,7 +500,7 @@ def create_bulk_internal_transfer(bank_transaction_names: list[str | int], bank_
|
||||
return output
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_internal_transfer(
|
||||
bank_transaction_name: str | int,
|
||||
posting_date: str | date,
|
||||
@@ -1057,7 +1057,7 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
|
||||
return alert_message, indicator
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
|
||||
# updated clear date of all the vouchers based on the bank transaction
|
||||
vouchers = frappe.parse_json(vouchers)
|
||||
|
||||
@@ -8,7 +8,6 @@ from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
@@ -98,40 +97,3 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
# assert API output post reconciliation
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 0)
|
||||
|
||||
def make_bank_transaction(self, date, deposit=100):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": date,
|
||||
"deposit": deposit,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
def test_get_bank_transactions_excludes_dates_after_to_date(self):
|
||||
self.make_bank_transaction(date=today())
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_auto_reconcile_message_for_no_matches(self):
|
||||
message, indicator = get_auto_reconcile_message([], [])
|
||||
self.assertEqual(indicator, "blue")
|
||||
self.assertIn("No matches", message)
|
||||
|
||||
def test_auto_reconcile_message_counts_and_pluralizes(self):
|
||||
# reconciled count is reported and the indicator turns green
|
||||
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
|
||||
self.assertEqual(indicator, "green")
|
||||
self.assertIn("2 Transaction(s) Reconciled", message)
|
||||
|
||||
# partially-reconciled label is singular for one, plural for many
|
||||
singular, _ = get_auto_reconcile_message(["p1"], [])
|
||||
self.assertIn("1 Transaction Partially Reconciled", singular)
|
||||
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
|
||||
self.assertIn("2 Transactions Partially Reconciled", plural)
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Closing Balance",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
@@ -190,7 +191,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-09 17:55:25.615942",
|
||||
"modified": "2026-05-08 17:55:25.615942",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import Log",
|
||||
|
||||
@@ -557,7 +557,7 @@ class BankStatementImportLog(Document):
|
||||
docname=self.name,
|
||||
)
|
||||
|
||||
if self.closing_balance is not None and self.end_date:
|
||||
if self.closing_balance and self.closing_balance > 0 and self.end_date:
|
||||
set_closing_balance_as_per_statement(
|
||||
self.bank_account, frappe.utils.getdate(self.end_date), self.closing_balance
|
||||
)
|
||||
|
||||
@@ -397,7 +397,7 @@ def unreconcile_transaction(transaction_name: str | int):
|
||||
frappe.get_doc(voucher["doctype"], voucher["name"]).cancel()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type: str, voucher_id: str | int):
|
||||
"""
|
||||
Removes a single payment entry from a bank transaction - for example only undoing one voucher instead of undoing the entire transaction
|
||||
|
||||
@@ -34,7 +34,7 @@ def upload_bank_statement():
|
||||
return {"columns": columns, "data": data}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
header_map = get_header_mapping(columns, bank_account)
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class BisectAccountingStatements(Document):
|
||||
self.get_report_summary()
|
||||
self.update_node()
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def bisect_left(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
@@ -198,7 +198,7 @@ class BisectAccountingStatements(Document):
|
||||
else:
|
||||
frappe.msgprint(_("No more children on Left"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def bisect_right(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
@@ -212,7 +212,7 @@ class BisectAccountingStatements(Document):
|
||||
else:
|
||||
frappe.msgprint(_("No more children on Right"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def move_up(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
|
||||
@@ -1,47 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import datetime
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBisectAccountingStatements(ERPNextTestSuite):
|
||||
"""The tool bisects a date range into a tree of Bisect Nodes down to single days.
|
||||
These cover the date validation and that the bisection cleanly partitions the range."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.delete("Bisect Nodes")
|
||||
|
||||
def _leaf_days(self):
|
||||
leaves = frappe.get_all(
|
||||
"Bisect Nodes",
|
||||
filters={"left_child": ["is", "not set"]},
|
||||
fields=["period_from_date", "period_to_date"],
|
||||
)
|
||||
# every leaf spans a single day
|
||||
for leaf in leaves:
|
||||
self.assertEqual(getdate(leaf.period_from_date), getdate(leaf.period_to_date))
|
||||
return sorted(getdate(leaf.period_from_date) for leaf in leaves)
|
||||
|
||||
def test_validate_dates_rejects_reversed_range(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.from_date = "2026-01-08"
|
||||
doc.to_date = "2026-01-01"
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_bfs_partitions_range_into_single_days(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.bfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
|
||||
|
||||
# the 8-day span Jan 1..Jan 8 becomes exactly 8 contiguous single-day leaves
|
||||
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
|
||||
|
||||
def test_dfs_produces_the_same_partition_as_bfs(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.dfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
|
||||
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
|
||||
pass
|
||||
|
||||
@@ -878,7 +878,7 @@ def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
|
||||
return from_year.year_start_date, to_year.year_end_date
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def revise_budget(budget_name: str):
|
||||
old_budget = frappe.get_doc("Budget", budget_name)
|
||||
|
||||
|
||||
@@ -1,67 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
DATE = "2026-06-15"
|
||||
|
||||
|
||||
class TestCashierClosing(ERPNextTestSuite):
|
||||
"""Cashier Closing reconciles a shift: it pulls outstanding invoices in a
|
||||
date/time window and rolls payments, expense, custody and returns into net_amount."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_invoice_in_window(self, rate=100):
|
||||
si = create_sales_invoice(rate=rate, qty=1, posting_date=DATE, do_not_submit=True)
|
||||
si.posting_time = "10:30:00"
|
||||
si.submit()
|
||||
si.reload() # read outstanding_amount as persisted after submit
|
||||
return si
|
||||
|
||||
def make_closing(self, user="Administrator", payments=None, **args):
|
||||
doc = frappe.new_doc("Cashier Closing")
|
||||
doc.user = user
|
||||
doc.date = args.get("date", DATE)
|
||||
doc.from_time = args.get("from_time", "09:00:00")
|
||||
doc.time = args.get("time", "18:00:00")
|
||||
for amount in payments or []:
|
||||
doc.append("payments", {"mode_of_payment": "Cash", "amount": amount})
|
||||
doc.expense = args.get("expense", 0)
|
||||
doc.custody = args.get("custody", 0)
|
||||
doc.returns = args.get("returns", 0)
|
||||
return doc
|
||||
|
||||
def test_from_time_must_be_before_to_time(self):
|
||||
doc = self.make_closing(from_time="18:00:00", time="09:00:00")
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_equal_from_and_to_time_is_rejected(self):
|
||||
# validate_time uses >=, so a zero-length window is also blocked
|
||||
doc = self.make_closing(from_time="09:00:00", time="09:00:00")
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_net_amount_rolls_up_outstanding_and_adjustments(self):
|
||||
si = self.make_invoice_in_window(rate=100)
|
||||
doc = self.make_closing(payments=[500], expense=50, custody=30, returns=20)
|
||||
doc.save()
|
||||
|
||||
# the in-window invoice is picked up as outstanding
|
||||
self.assertEqual(doc.outstanding_amount, si.outstanding_amount)
|
||||
# net = payments + outstanding + expense - custody + returns
|
||||
self.assertEqual(doc.net_amount, 500 + si.outstanding_amount + 50 - 30 + 20)
|
||||
|
||||
def test_outstanding_is_scoped_to_the_invoice_owner(self):
|
||||
# The invoice is created by Administrator; a closing for a different user does
|
||||
# not see it. NOTE: get_outstanding keys on Sales Invoice.owner (the document
|
||||
# creator) rather than an explicit cashier/POS-user field, which is fragile when
|
||||
# invoices are created by a shared or system user.
|
||||
self.make_invoice_in_window(rate=100)
|
||||
doc = self.make_closing(user="Guest", payments=[500])
|
||||
doc.save()
|
||||
self.assertEqual(doc.outstanding_amount, 0)
|
||||
self.assertEqual(doc.net_amount, 500)
|
||||
pass
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
|
||||
build_forest,
|
||||
validate_columns,
|
||||
validate_missing_roots,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
# columns: account_name, parent_account, account_number, parent_account_number,
|
||||
# is_group, account_type, root_type, account_currency
|
||||
ROOT = ["Assets", "Assets", "", "", 1, "", "Asset", "INR"]
|
||||
CHILD = ["Cash", "Assets", "", "", 0, "Cash", "Asset", "INR"]
|
||||
|
||||
|
||||
class TestChartofAccountsImporter(ERPNextTestSuite):
|
||||
"""The importer parses an uploaded CoA into a nested tree and validates its
|
||||
shape. These cover the parsing/validation helpers without a file upload."""
|
||||
|
||||
def test_validate_columns_rejects_blank_file(self):
|
||||
self.assertRaises(frappe.ValidationError, validate_columns, [])
|
||||
|
||||
def test_validate_columns_requires_eight_columns(self):
|
||||
self.assertRaises(frappe.ValidationError, validate_columns, [["a", "b", "c"]])
|
||||
# the standard template width passes
|
||||
validate_columns([ROOT])
|
||||
|
||||
def test_build_forest_nests_child_under_parent(self):
|
||||
forest = build_forest([ROOT, CHILD])
|
||||
self.assertIn("Assets", forest)
|
||||
self.assertIn("Cash", forest["Assets"])
|
||||
|
||||
def test_build_forest_rejects_unknown_parent(self):
|
||||
orphan = ["Cash", "Missing Parent", "", "", 0, "Cash", "Asset", "INR"]
|
||||
self.assertRaises(frappe.ValidationError, build_forest, [orphan])
|
||||
|
||||
def test_build_forest_requires_account_name(self):
|
||||
nameless = ["", "Assets", "", "", 0, "Cash", "Asset", "INR"]
|
||||
self.assertRaises(frappe.ValidationError, build_forest, [ROOT, nameless])
|
||||
|
||||
def test_validate_missing_roots_requires_all_root_types(self):
|
||||
present = ("Asset", "Liability", "Expense", "Income") # Equity missing
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
validate_missing_roots,
|
||||
[{"root_type": rt} for rt in present],
|
||||
)
|
||||
# all five root types present -> no error
|
||||
validate_missing_roots(
|
||||
[{"root_type": rt} for rt in ("Asset", "Liability", "Expense", "Income", "Equity")]
|
||||
)
|
||||
pass
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from erpnext.accounts.doctype.sales_invoice.mapper import (
|
||||
create_dunning as create_dunning_from_sales_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
||||
create_sales_invoice,
|
||||
create_sales_invoice_against_cost_center,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -153,37 +152,6 @@ class TestDunning(ERPNextTestSuite):
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
def test_dunning_outstanding_uses_transaction_currency(self):
|
||||
"""
|
||||
Regression for #56006: dunning outstanding must be in the invoice transaction
|
||||
currency, not in the party account currency.
|
||||
|
||||
A USD invoice posted against an INR receivable account stores
|
||||
outstanding_amount in INR (party account currency). The overdue payment
|
||||
row on the resulting Dunning must carry the USD amount, not the INR amount.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(today(), -10),
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
rate=100,
|
||||
debit_to="Debtors - _TC",
|
||||
)
|
||||
|
||||
# Sanity-check the invoice state before creating the dunning
|
||||
self.assertEqual(si.currency, "USD")
|
||||
self.assertEqual(si.outstanding_amount, 5000.0) # INR (party account currency)
|
||||
self.assertEqual(si.payment_schedule[0].outstanding, 100.0) # USD (transaction currency)
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(si.name)
|
||||
|
||||
self.assertEqual(len(dunning.overdue_payments), 1)
|
||||
# Must reflect 100 USD, not 5000 INR mislabelled as USD
|
||||
self.assertEqual(dunning.overdue_payments[0].outstanding, 100.0)
|
||||
|
||||
def test_dunning_not_affected_by_standalone_credit_note(self):
|
||||
"""
|
||||
Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked.
|
||||
|
||||
@@ -22,27 +22,17 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus == 1) {
|
||||
frappe.call({
|
||||
method: "check_journal_and_reversal",
|
||||
method: "check_journal_entry_condition",
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
if (!r.message.journals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
} else if (!r.message.reversals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Reversal Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_reverse_journal(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -110,14 +100,6 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
},
|
||||
});
|
||||
},
|
||||
make_reverse_journal: function (frm) {
|
||||
frappe.call({
|
||||
method: "make_reverse_journal",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Reversing Journals..."),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Exchange Rate Revaluation Account", {
|
||||
|
||||
@@ -9,7 +9,7 @@ from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Criterion, Order
|
||||
from frappe.query_builder.functions import Max, NullIf, Sum
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
|
||||
@@ -91,31 +91,25 @@ class ExchangeRateRevaluation(Document):
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
|
||||
self.ignore_linked_doctypes = "GL Entry"
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_journal_and_reversal(self):
|
||||
def check_journal_entry_condition(self):
|
||||
exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
|
||||
|
||||
journals_posted = False
|
||||
reversals_posted = False
|
||||
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
qb.from_(jea)
|
||||
.select(jea.parent)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run(pluck="name")
|
||||
.run()
|
||||
)
|
||||
|
||||
if journals:
|
||||
gle = qb.DocType("GL Entry")
|
||||
total_amt = (
|
||||
@@ -130,31 +124,12 @@ class ExchangeRateRevaluation(Document):
|
||||
.run()
|
||||
)
|
||||
|
||||
if total_amt and total_amt[0][0] == self.total_gain_loss:
|
||||
journals_posted = True
|
||||
if total_amt and total_amt[0][0] != self.total_gain_loss:
|
||||
return True
|
||||
else:
|
||||
journals_posted = False
|
||||
return False
|
||||
|
||||
# reverse journals
|
||||
reverse_journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.notnull())
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
if reverse_journals:
|
||||
reversals_posted = True
|
||||
else:
|
||||
reversals_posted = False
|
||||
|
||||
return {"journals_posted": journals_posted, "reversals_posted": reversals_posted}
|
||||
return True
|
||||
|
||||
def fetch_and_calculate_accounts_data(self):
|
||||
accounts = self.get_accounts_data()
|
||||
@@ -372,7 +347,6 @@ class ExchangeRateRevaluation(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_jv_entries(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
zero_balance_jv = self.make_jv_for_zero_balance()
|
||||
if zero_balance_jv:
|
||||
frappe.msgprint(
|
||||
@@ -601,38 +575,6 @@ class ExchangeRateRevaluation(Document):
|
||||
journal_entry.save()
|
||||
return journal_entry
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
if journals:
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.submit()
|
||||
frappe.msgprint(
|
||||
_("Revaluation journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
"""
|
||||
|
||||
@@ -132,8 +132,7 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
@@ -222,8 +221,7 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
@@ -300,145 +298,3 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
|
||||
for key, _val in expected_data.items():
|
||||
self.assertEqual(expected_data.get(key), account_details.get(key))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
|
||||
)
|
||||
def test_05_revaluation_journal_reversal(self):
|
||||
"""
|
||||
Test reversing of revaluation journals
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debtors_usd,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_submit=1,
|
||||
)
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 80
|
||||
si.save().submit()
|
||||
|
||||
err = frappe.new_doc("Exchange Rate Revaluation")
|
||||
err.company = self.company
|
||||
err.posting_date = today()
|
||||
err.fetch_and_calculate_accounts_data()
|
||||
self.assertEqual(len(err.accounts), 1)
|
||||
err.save().submit()
|
||||
|
||||
gain_loss_account = err.get_for_unrealized_gain_loss_account()
|
||||
usd_account = err.accounts[0].account
|
||||
old_balance = err.accounts[0].balance_in_base_currency
|
||||
new_balance = err.accounts[0].new_balance_in_base_currency
|
||||
total_gain_loss = err.total_gain_loss
|
||||
|
||||
# Create JV for ERR
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
|
||||
je = je.submit()
|
||||
|
||||
je.reload()
|
||||
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
|
||||
self.assertEqual(len(je.accounts), 3)
|
||||
expected = [
|
||||
(usd_account, new_balance, 0.0, 100.0, 0.0),
|
||||
(usd_account, 0.0, old_balance, 0.0, 100.0),
|
||||
(gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss),
|
||||
]
|
||||
actual = []
|
||||
for acc in je.accounts:
|
||||
actual.append(
|
||||
(
|
||||
acc.account,
|
||||
acc.debit,
|
||||
acc.credit,
|
||||
acc.debit_in_account_currency,
|
||||
acc.credit_in_account_currency,
|
||||
)
|
||||
)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
# Assert reversals are not posted
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertFalse(ret.get("reversals_posted"))
|
||||
|
||||
err.make_reverse_journal()
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertTrue(ret.get("reversals_posted"))
|
||||
|
||||
reverse_jv = frappe.db.get_all(
|
||||
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
|
||||
)
|
||||
self.assertIsNotNone(reverse_jv)
|
||||
|
||||
|
||||
class TestExchangeRateRevaluationValidation(ERPNextTestSuite):
|
||||
"""Validation and gain/loss calculation paths, exercised on the document directly
|
||||
so they don't need the multi-currency GL setup the integration tests above build."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.company = "_Test Company"
|
||||
|
||||
def _revaluation_with_rows(self, rows, rounding_loss_allowance=0.05):
|
||||
doc = frappe.new_doc("Exchange Rate Revaluation")
|
||||
doc.company = self.company
|
||||
doc.posting_date = today()
|
||||
doc.rounding_loss_allowance = rounding_loss_allowance
|
||||
for row in rows:
|
||||
doc.append("accounts", row)
|
||||
return doc
|
||||
|
||||
def test_rounding_loss_allowance_must_be_between_0_and_1(self):
|
||||
for bad in (-0.1, 1, 1.5):
|
||||
doc = self._revaluation_with_rows([], rounding_loss_allowance=bad)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
# values inside [0, 1) are accepted, at the lower bound and mid-range
|
||||
for good in (0.0, 0.5):
|
||||
self._revaluation_with_rows([], rounding_loss_allowance=good).validate()
|
||||
|
||||
def test_gain_loss_computed_and_split_by_zero_balance(self):
|
||||
doc = self._revaluation_with_rows(
|
||||
[
|
||||
# open (unbooked) row: base balance moved 1000 -> 1100, a 100 gain
|
||||
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
|
||||
# already-settled (zero_balance) row carries a booked loss of 40
|
||||
{"zero_balance": 1, "gain_loss": -40},
|
||||
]
|
||||
)
|
||||
doc.validate()
|
||||
|
||||
# gain_loss is derived only for open rows; the zero-balance row keeps its value
|
||||
self.assertEqual(doc.accounts[0].gain_loss, 100)
|
||||
self.assertEqual(doc.gain_loss_unbooked, 100)
|
||||
self.assertEqual(doc.gain_loss_booked, -40)
|
||||
self.assertEqual(doc.total_gain_loss, 60)
|
||||
|
||||
def test_before_submit_drops_rows_without_gain_loss(self):
|
||||
doc = self._revaluation_with_rows(
|
||||
[
|
||||
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
|
||||
{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500},
|
||||
]
|
||||
)
|
||||
doc.validate() # second row nets to a 0 gain_loss
|
||||
doc.remove_accounts_without_gain_loss()
|
||||
self.assertEqual(len(doc.accounts), 1)
|
||||
self.assertEqual(doc.accounts[0].gain_loss, 100)
|
||||
|
||||
def test_before_submit_requires_at_least_one_gain_loss_row(self):
|
||||
doc = self._revaluation_with_rows(
|
||||
[{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}]
|
||||
)
|
||||
doc.validate()
|
||||
self.assertRaises(frappe.ValidationError, doc.remove_accounts_without_gain_loss)
|
||||
|
||||
@@ -255,27 +255,16 @@ class FinancialReportEngine:
|
||||
|
||||
if filters.get("presentation_currency"):
|
||||
frappe.msgprint(
|
||||
title=_("Unsupported Feature"),
|
||||
msg=_("Currency filters are currently unsupported in Custom Financial Report."),
|
||||
indicator="orange",
|
||||
title=_("Not Supported"),
|
||||
msg=_("Currency filters are currently unsupported in Custom Financial Report"),
|
||||
)
|
||||
|
||||
# Margin view is dependent on first row being an income account. Hence not supported.
|
||||
# Way to implement this would be using calculated rows with formulas.
|
||||
supported_views = ("Report", "Growth")
|
||||
if (view := filters.get("selected_view")) and view not in supported_views:
|
||||
frappe.msgprint(
|
||||
indicator="orange",
|
||||
title=_("Not Supported"),
|
||||
msg=_("{0} view is currently unsupported in Custom Financial Report").format(view),
|
||||
)
|
||||
|
||||
if filters.get("group_by_dimension"):
|
||||
frappe.msgprint(
|
||||
indicator="orange",
|
||||
title=_("Not Supported"),
|
||||
msg=_("Dimension-based grouping is currently unsupported in Custom Financial Report"),
|
||||
)
|
||||
frappe.msgprint(_("{0} view is currently unsupported in Custom Financial Report.").format(view))
|
||||
|
||||
def _initialize_context(self, filters: dict[str, Any]) -> ReportContext:
|
||||
template_name = filters.get("report_template")
|
||||
@@ -1871,51 +1860,28 @@ class GrowthViewTransformer:
|
||||
self.formatted_rows = context.raw_data.get("formatted_data", [])
|
||||
self.period_list = context.period_list
|
||||
|
||||
def transform(self):
|
||||
def transform(self) -> None:
|
||||
for row_data in self.formatted_rows:
|
||||
if row_data.get("is_blank_line"):
|
||||
continue
|
||||
|
||||
if row_data.get("segment_values"):
|
||||
self._transform_segmented_row(row_data)
|
||||
else:
|
||||
self._transform_single_row(row_data)
|
||||
transformed_values = {}
|
||||
for i in range(len(self.period_list)):
|
||||
current_period = self.period_list[i]["key"]
|
||||
|
||||
def _compute_growth_values(self, source: dict) -> dict:
|
||||
transformed = {}
|
||||
current_value = row_data[current_period]
|
||||
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
|
||||
|
||||
for i, period in enumerate(self.period_list):
|
||||
current_period = period["key"]
|
||||
current_value = source.get(current_period)
|
||||
if i == 0:
|
||||
transformed_values[current_period] = current_value
|
||||
else:
|
||||
growth_percent = self._calculate_growth(previous_value, current_value)
|
||||
transformed_values[current_period] = growth_percent
|
||||
|
||||
if current_value in (None, ""):
|
||||
continue
|
||||
|
||||
if i == 0:
|
||||
transformed[current_period] = current_value
|
||||
else:
|
||||
previous_period = self.period_list[i - 1]["key"]
|
||||
previous_value = source.get(previous_period) or 0
|
||||
transformed[current_period] = self._calculate_growth(previous_value, current_value)
|
||||
|
||||
return transformed
|
||||
|
||||
def _transform_single_row(self, row_data: dict):
|
||||
row_data.update(self._compute_growth_values(row_data))
|
||||
|
||||
def _transform_segmented_row(self, row_data: dict):
|
||||
for seg_id, seg_data in row_data.get("segment_values", {}).items():
|
||||
if seg_data.get("is_blank_line"):
|
||||
continue
|
||||
|
||||
transformed = self._compute_growth_values(seg_data)
|
||||
seg_data.update(transformed)
|
||||
|
||||
for period_key, value in transformed.items():
|
||||
row_data[f"{seg_id}_{period_key}"] = value
|
||||
row_data.update(transformed_values)
|
||||
|
||||
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
|
||||
if current_value in (None, ""):
|
||||
if current_value is None:
|
||||
return None
|
||||
|
||||
if previous_value == 0 and current_value > 0:
|
||||
|
||||
@@ -1,62 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
TAX_ACCOUNT = "_Test Account VAT - _TC"
|
||||
RECEIVABLE_ACCOUNT = "Debtors - _TC"
|
||||
|
||||
|
||||
class TestItemTaxTemplate(ERPNextTestSuite):
|
||||
"""Item Tax Template validates its tax rows: each account must belong to the
|
||||
company, be a tax-like account type, and appear only once."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_template(self, rows, title="_Test ITT"):
|
||||
doc = frappe.new_doc("Item Tax Template")
|
||||
doc.title = f"{title} {frappe.generate_hash(length=6)}"
|
||||
doc.company = COMPANY
|
||||
for account, rate, not_applicable in rows:
|
||||
doc.append(
|
||||
"taxes",
|
||||
{"tax_type": account, "tax_rate": rate, "not_applicable": not_applicable},
|
||||
)
|
||||
return doc
|
||||
|
||||
def test_valid_template_saves_and_is_named_with_abbr(self):
|
||||
doc = self.make_template([(TAX_ACCOUNT, 9, 0)])
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name.endswith(" - _TC"))
|
||||
self.assertTrue(doc.name.startswith(doc.title))
|
||||
|
||||
def test_duplicate_tax_type_throws(self):
|
||||
doc = self.make_template([(TAX_ACCOUNT, 9, 0), (TAX_ACCOUNT, 5, 0)])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_account_of_wrong_company_throws(self):
|
||||
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
|
||||
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
|
||||
doc = self.make_template([(other_account, 9, 0)])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_disallowed_account_type_throws(self):
|
||||
# a Receivable account is not Tax/Chargeable/Income/Expense
|
||||
doc = self.make_template([(RECEIVABLE_ACCOUNT, 9, 0)])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_not_applicable_row_has_rate_zeroed(self):
|
||||
doc = self.make_template([(TAX_ACCOUNT, 18, 1)])
|
||||
doc.insert()
|
||||
self.assertEqual(doc.taxes[0].tax_rate, 0)
|
||||
|
||||
def test_negative_tax_rate_is_accepted(self):
|
||||
# SUSPECTED BUG: validate never bounds tax_rate, so a negative (or >100) rate
|
||||
# saves silently. Locking the current (wrong) behaviour.
|
||||
doc = self.make_template([(TAX_ACCOUNT, -5, 0)])
|
||||
doc.insert()
|
||||
self.assertEqual(doc.taxes[0].tax_rate, -5)
|
||||
pass
|
||||
|
||||
@@ -29,7 +29,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
|
||||
refresh(frm) {
|
||||
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
|
||||
erpnext.journal_entry.lock_reversal_entry(frm);
|
||||
frm.set_read_only();
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
@@ -232,13 +232,6 @@ Object.assign(erpnext.journal_entry, {
|
||||
}
|
||||
},
|
||||
|
||||
lock_reversal_entry(frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
add_custom_buttons(frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
frappe.listview_settings["Journal Entry"] = {
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark", "reversal_of"],
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "remark"],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.docstatus === 1) {
|
||||
if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") {
|
||||
return [__("Reversal Of Exchange Rate Revaluation"), "blue"];
|
||||
}
|
||||
return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`];
|
||||
}
|
||||
},
|
||||
|
||||
@@ -94,12 +94,11 @@ class AssetService:
|
||||
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)
|
||||
precision = je_row.precision("debit")
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.doc.posting_date
|
||||
and not d.journal_entry
|
||||
and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision)
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||
|
||||
|
||||
@@ -45,20 +45,6 @@ class JournalEntryTemplate(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_party()
|
||||
self.validate_account_company()
|
||||
|
||||
def validate_account_company(self):
|
||||
"""Each row's account must belong to the template's company."""
|
||||
for account in self.accounts:
|
||||
if (
|
||||
account.account
|
||||
and frappe.get_cached_value("Account", account.account, "company") != self.company
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Account {1} does not belong to company {2}").format(
|
||||
account.idx, account.account, self.company
|
||||
)
|
||||
)
|
||||
|
||||
def validate_party(self):
|
||||
"""
|
||||
|
||||
@@ -1,45 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestJournalEntryTemplate(ERPNextTestSuite):
|
||||
"""Journal Entry Template's only real rule is validate_party: party_type is
|
||||
allowed only on Receivable/Payable accounts, and a party needs a party_type."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_template(self, rows, company=COMPANY):
|
||||
doc = frappe.new_doc("Journal Entry Template")
|
||||
doc.template_title = f"_Test JET {frappe.generate_hash(length=6)}"
|
||||
doc.company = company
|
||||
doc.voucher_type = "Journal Entry"
|
||||
doc.naming_series = frappe.get_meta("Journal Entry").get_field("naming_series").options.split("\n")[0]
|
||||
for row in rows:
|
||||
doc.append("accounts", row)
|
||||
return doc
|
||||
|
||||
def test_party_type_only_on_receivable_or_payable_account(self):
|
||||
# Cash is neither Receivable nor Payable, so a party_type here is invalid
|
||||
doc = self.make_template([{"account": "Cash - _TC", "party_type": "Customer"}])
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_party_requires_party_type(self):
|
||||
doc = self.make_template([{"account": "Debtors - _TC", "party": "_Test Customer"}])
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_account_from_other_company_is_rejected(self):
|
||||
other_receivable = frappe.db.get_value(
|
||||
"Account", {"company": "_Test Company 1", "account_type": "Receivable", "is_group": 0}, "name"
|
||||
)
|
||||
self.assertTrue(other_receivable, "need a receivable account in _Test Company 1")
|
||||
doc = self.make_template(
|
||||
[{"account": other_receivable, "party_type": "Customer", "party": "_Test Customer"}]
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
pass
|
||||
|
||||
@@ -8,7 +8,7 @@ frappe.ui.form.on("Loyalty Program", {
|
||||
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<h4>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
|
||||
@@ -5,59 +5,9 @@ import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestModeofPayment(ERPNextTestSuite):
|
||||
"""Mode of Payment validates its per-company default accounts (account company
|
||||
must match the row, no company twice) and blocks disabling while a POS Profile
|
||||
still references it."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_mop(self, accounts=None, enabled=1):
|
||||
doc = frappe.new_doc("Mode of Payment")
|
||||
doc.mode_of_payment = f"_Test MoP {frappe.generate_hash(length=6)}"
|
||||
doc.type = "General"
|
||||
doc.enabled = enabled
|
||||
for company, account in accounts or []:
|
||||
doc.append("accounts", {"company": company, "default_account": account})
|
||||
return doc
|
||||
|
||||
def test_valid_mode_of_payment_saves(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")])
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_account_of_wrong_company_throws(self):
|
||||
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
|
||||
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
|
||||
doc = self.make_mop(accounts=[(COMPANY, other_account)])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_repeating_company_throws(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC"), (COMPANY, "Debtors - _TC")])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_disabling_mode_referenced_by_pos_profile_is_not_blocked(self):
|
||||
# SUSPECTED BUG: validate_pos_mode_of_payment queries "Sales Invoice Payment"
|
||||
# rows with parenttype "POS Profile", but a POS Profile's payments are stored
|
||||
# as "POS Payment Method" rows. The filter never matches, so the guard is dead
|
||||
# and a mode still referenced by a POS Profile disables without complaint.
|
||||
# Locking the current (wrong) behaviour so a fix to the guard trips this test.
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
|
||||
make_pos_profile() # its payments row references the "Cash" mode of payment
|
||||
cash = frappe.get_doc("Mode of Payment", "Cash")
|
||||
cash.enabled = 0
|
||||
cash.save()
|
||||
self.assertEqual(frappe.db.get_value("Mode of Payment", "Cash", "enabled"), 0)
|
||||
|
||||
def test_disabling_unreferenced_mode_succeeds(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")], enabled=0)
|
||||
doc.insert()
|
||||
self.assertEqual(doc.enabled, 0)
|
||||
pass
|
||||
|
||||
|
||||
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):
|
||||
|
||||
@@ -1,67 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.accounts.doctype.monthly_distribution.monthly_distribution import (
|
||||
get_percentage,
|
||||
get_periodwise_distribution_data,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestMonthlyDistribution(ERPNextTestSuite):
|
||||
"""Monthly Distribution spreads an amount across months. validate() enforces a
|
||||
100% total; get_percentage() sums the months that fall inside a period window."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_distribution(self, allocations):
|
||||
doc = frappe.new_doc("Monthly Distribution")
|
||||
doc.distribution_id = f"_Test MD {frappe.generate_hash(length=6)}"
|
||||
for month, pct in allocations:
|
||||
doc.append("percentages", {"month": month, "percentage_allocation": pct})
|
||||
return doc
|
||||
|
||||
def test_get_months_populates_twelve_even_rows(self):
|
||||
doc = frappe.new_doc("Monthly Distribution")
|
||||
doc.distribution_id = "_Test MD Even"
|
||||
doc.get_months()
|
||||
|
||||
self.assertEqual(len(doc.percentages), 12)
|
||||
self.assertEqual(doc.percentages[0].month, "January")
|
||||
self.assertEqual(doc.percentages[-1].month, "December")
|
||||
self.assertEqual([d.idx for d in doc.percentages], list(range(1, 13)))
|
||||
for d in doc.percentages:
|
||||
self.assertAlmostEqual(d.percentage_allocation, 100.0 / 12, places=4)
|
||||
# the auto-populated rows round to exactly 100 and pass validation
|
||||
doc.validate()
|
||||
|
||||
def test_validate_rejects_total_other_than_100(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30)]) # sums to 80
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_get_percentage_sums_period_window(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
|
||||
doc.insert() # total is 100, so validate passes
|
||||
|
||||
# a quarter starting in January covers Jan+Feb+Mar
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-01-01"), 3), 100)
|
||||
# a single month picks up only that month
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-02-01"), 1), 30)
|
||||
# months with no row simply contribute 0 (there is no guard that all 12 exist)
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-04-01"), 1), 0)
|
||||
|
||||
def test_periodwise_distribution_maps_each_period(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
|
||||
doc.insert()
|
||||
|
||||
period_list = [
|
||||
frappe._dict(key="q1", from_date=getdate("2026-01-01")),
|
||||
frappe._dict(key="q2", from_date=getdate("2026-04-01")),
|
||||
]
|
||||
data = get_periodwise_distribution_data(doc.name, period_list, "Quarterly")
|
||||
self.assertEqual(data["q1"], 100) # Jan+Feb+Mar
|
||||
self.assertEqual(data["q2"], 0) # Apr+May+Jun carry no allocation
|
||||
pass
|
||||
|
||||
@@ -24,22 +24,15 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
setTimeout(
|
||||
() => {
|
||||
frm.doc.import_in_progress = false;
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
frm.page.clear_indicator();
|
||||
frm.dashboard.hide_progress();
|
||||
|
||||
if (!data.errors) {
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
const message =
|
||||
frm.doc.invoice_type == "Sales"
|
||||
? __("Opening Sales Invoice(s) have been created.")
|
||||
: __("Opening Purchase Invoice(s) have been created.");
|
||||
frappe.show_alert({
|
||||
message: message,
|
||||
indicator: "green",
|
||||
});
|
||||
if (frm.doc.invoice_type == "Sales") {
|
||||
frappe.msgprint(__("Opening Sales Invoices have been created."));
|
||||
} else {
|
||||
frm.refresh_fields();
|
||||
frappe.msgprint(__("Opening Purchase Invoices have been created."));
|
||||
}
|
||||
},
|
||||
1500,
|
||||
|
||||
@@ -281,7 +281,6 @@ class OpeningInvoiceCreationTool(Document):
|
||||
def start_import(invoices):
|
||||
errors = 0
|
||||
names = []
|
||||
total = len(invoices)
|
||||
for idx, d in enumerate(invoices):
|
||||
# Scope each invoice to a savepoint so a failure only undoes that invoice.
|
||||
# A plain rollback() would discard the whole transaction — including invoices
|
||||
@@ -290,11 +289,11 @@ def start_import(invoices):
|
||||
# postgres they would be lost). Rolling back to a savepoint keeps both.
|
||||
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
|
||||
frappe.db.savepoint(savepoint)
|
||||
is_last = idx == total - 1
|
||||
try:
|
||||
invoice_number = None
|
||||
if d.invoice_number:
|
||||
invoice_number = d.invoice_number
|
||||
publish(idx, len(invoices), d.doctype)
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
@@ -302,12 +301,10 @@ def start_import(invoices):
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit()
|
||||
names.append(doc.name)
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
except Exception:
|
||||
errors += 1
|
||||
frappe.db.rollback(save_point=savepoint)
|
||||
doc.log_error("Opening invoice creation failed")
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
if errors:
|
||||
frappe.msgprint(
|
||||
_("You had {0} errors while creating opening invoices. Check {1} for more details").format(
|
||||
@@ -319,7 +316,7 @@ def start_import(invoices):
|
||||
return names
|
||||
|
||||
|
||||
def publish(index, total, doctype, errors=None):
|
||||
def publish(index, total, doctype):
|
||||
frappe.publish_realtime(
|
||||
"opening_invoice_creation_progress",
|
||||
dict(
|
||||
@@ -327,7 +324,6 @@ def publish(index, total, doctype, errors=None):
|
||||
message=_("Creating {} out of {} {}").format(index + 1, total, doctype),
|
||||
count=index + 1,
|
||||
total=total,
|
||||
errors=errors,
|
||||
),
|
||||
user=frappe.session.user,
|
||||
)
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Outstanding Amount",
|
||||
"options": "Company:company:default_currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -137,7 +136,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-02 15:17:11.938499",
|
||||
"modified": "2026-04-29 17:08:15.617047",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
|
||||
@@ -1,67 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.party_link.party_link import create_party_link
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
CUSTOMER = "_Test Customer"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_2 = "_Test Supplier 1"
|
||||
|
||||
|
||||
class TestPartyLink(ERPNextTestSuite):
|
||||
"""Party Link ties a Customer and a Supplier together as one underlying party.
|
||||
validate() constrains the primary role and blocks duplicate links."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_create_party_link_with_customer_primary(self):
|
||||
link = create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
self.assertEqual(link.primary_role, "Customer")
|
||||
self.assertEqual(link.secondary_role, "Supplier")
|
||||
self.assertEqual(link.primary_party, CUSTOMER)
|
||||
self.assertEqual(link.secondary_party, SUPPLIER)
|
||||
self.assertTrue(frappe.db.exists("Party Link", link.name))
|
||||
|
||||
def test_create_party_link_with_supplier_primary(self):
|
||||
link = create_party_link("Supplier", SUPPLIER, CUSTOMER)
|
||||
self.assertEqual(link.primary_role, "Supplier")
|
||||
self.assertEqual(link.secondary_role, "Customer")
|
||||
self.assertEqual(link.primary_party, SUPPLIER)
|
||||
self.assertEqual(link.secondary_party, CUSTOMER)
|
||||
self.assertTrue(frappe.db.exists("Party Link", link.name))
|
||||
|
||||
def test_primary_role_must_be_customer_or_supplier(self):
|
||||
doc = frappe.new_doc("Party Link")
|
||||
doc.primary_role = "Employee"
|
||||
doc.primary_party = CUSTOMER
|
||||
doc.secondary_role = "Supplier"
|
||||
doc.secondary_party = SUPPLIER
|
||||
# validate() alone isolates the role rule from the dynamic-link checks
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_duplicate_link_throws(self):
|
||||
create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
dup = frappe.new_doc("Party Link")
|
||||
dup.primary_role = "Customer"
|
||||
dup.primary_party = CUSTOMER
|
||||
dup.secondary_role = "Supplier"
|
||||
dup.secondary_party = SUPPLIER
|
||||
self.assertRaises(frappe.ValidationError, dup.insert)
|
||||
|
||||
def test_party_can_wrongly_be_primary_in_two_links(self):
|
||||
# SUSPECTED BUG: the uniqueness checks are asymmetric - a party already a
|
||||
# *primary* in another link isn't blocked, so one customer can be linked to two
|
||||
# different suppliers, breaking the 1:1 mapping. Locking the current (wrong)
|
||||
# behaviour so a fix that blocks primary reuse trips this test.
|
||||
create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
link2 = frappe.new_doc("Party Link")
|
||||
link2.primary_role = "Customer"
|
||||
link2.primary_party = CUSTOMER
|
||||
link2.secondary_role = "Supplier"
|
||||
link2.secondary_party = SUPPLIER_2
|
||||
link2.insert()
|
||||
self.assertTrue(frappe.db.exists("Party Link", link2.name))
|
||||
pass
|
||||
|
||||
@@ -414,17 +414,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
show_general_ledger: function (frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(__("Ledger"), function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
frm.add_custom_button(
|
||||
__("Ledger"),
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -2424,9 +2424,6 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost
|
||||
if not frappe.db.exists(party_type, party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
|
||||
frappe.has_permission(party_type, ptype, party, throw=True)
|
||||
|
||||
party_account = get_party_account(party_type, party, company)
|
||||
account_currency = get_account_currency(party_account)
|
||||
_party_name = "title" if party_type == "Shareholder" else party_type.lower() + "_name"
|
||||
@@ -2434,7 +2431,7 @@ def get_party_details(company: str, party_type: str, party: str, date: str, cost
|
||||
|
||||
if party_type in ["Customer", "Supplier"]:
|
||||
party_bank_account = get_party_bank_account(party_type, party)
|
||||
bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False)
|
||||
bank_account = get_default_company_bank_account(company, party_type, party)
|
||||
|
||||
return {
|
||||
"party_account": party_account,
|
||||
@@ -2530,7 +2527,9 @@ def get_reference_details(
|
||||
exchange_rate = get_exchange_rate(party_account_currency, company_currency, ref_doc.posting_date)
|
||||
else:
|
||||
exchange_rate = 1
|
||||
outstanding_amount, total_amount = get_outstanding_on_journal_entry(reference_name, party_type, party)
|
||||
outstanding_amount, total_amount = get_outstanding_on_journal_entry(
|
||||
reference_name, party_type, party
|
||||
)
|
||||
|
||||
elif reference_doctype == "Payment Entry":
|
||||
if reverse_payment_details := frappe.db.get_all(
|
||||
|
||||
@@ -246,62 +246,6 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", pi.name, "outstanding_amount"))
|
||||
self.assertEqual(outstanding_amount, 0)
|
||||
|
||||
def test_pay_multiple_purchase_invoices_in_one_entry(self):
|
||||
pi1 = make_purchase_invoice() # outstanding 250
|
||||
pi2 = make_purchase_invoice() # outstanding 250
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", pi1.name, bank_account="_Test Cash - _TC")
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
"reference_doctype": "Purchase Invoice",
|
||||
"reference_name": pi2.name,
|
||||
"total_amount": pi2.grand_total,
|
||||
"outstanding_amount": pi2.outstanding_amount,
|
||||
"allocated_amount": pi2.outstanding_amount,
|
||||
},
|
||||
)
|
||||
pe.paid_amount = pe.received_amount = (
|
||||
pe.references[0].allocated_amount + pe.references[1].allocated_amount
|
||||
)
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(pe.total_allocated_amount, 500)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi1.name, "outstanding_amount"), 0)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi2.name, "outstanding_amount"), 0)
|
||||
|
||||
def test_unallocated_amount_on_overpaid_purchase_payment(self):
|
||||
pi = make_purchase_invoice() # outstanding 250
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
|
||||
pe.paid_amount = pe.references[0].allocated_amount + 200 # overpay -> 200 advance
|
||||
pe.received_amount = pe.paid_amount
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual(pe.unallocated_amount, 200)
|
||||
|
||||
# end-to-end: submitting posts a balanced GL for the full paid amount (250
|
||||
# settling the invoice + 200 advance)
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pe.name, "is_cancelled": 0},
|
||||
fields=["debit", "credit"],
|
||||
)
|
||||
self.assertTrue(gl_entries, "Submitted payment produced no GL entries")
|
||||
self.assertEqual(flt(sum(e.debit for e in gl_entries)), flt(sum(e.credit for e in gl_entries)))
|
||||
self.assertEqual(flt(sum(e.debit for e in gl_entries)), 450)
|
||||
|
||||
def test_overallocation_against_purchase_invoice_throws(self):
|
||||
pi = make_purchase_invoice() # outstanding 250
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
|
||||
pe.references[0].allocated_amount += 100 # 350 > 250 outstanding
|
||||
pe.paid_amount = pe.received_amount = pe.references[0].allocated_amount
|
||||
self.assertRaises(frappe.ValidationError, pe.insert)
|
||||
|
||||
def test_payment_against_sales_invoice_to_check_status(self):
|
||||
si = create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
@@ -2373,65 +2317,3 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
|
||||
customer.save()
|
||||
customer = customer.name
|
||||
return customer
|
||||
|
||||
|
||||
class TestPaymentEntryValidation(ERPNextTestSuite):
|
||||
"""Field-level validations invoked on the document directly, covering branches the
|
||||
integration suite above doesn't reach (no GL / reconciliation setup needed)."""
|
||||
|
||||
def make_pe(self, **fields):
|
||||
doc = frappe.new_doc("Payment Entry")
|
||||
doc.update(fields)
|
||||
return doc
|
||||
|
||||
def test_payment_type_must_be_a_known_value(self):
|
||||
self.assertRaises(frappe.ValidationError, self.make_pe(payment_type="Foo").validate_payment_type)
|
||||
self.make_pe(payment_type="Receive").validate_payment_type() # valid value passes
|
||||
|
||||
def test_nonexistent_party_is_rejected(self):
|
||||
doc = self.make_pe(party_type="Customer", party="__No Such Customer__")
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_party_details)
|
||||
|
||||
def test_amount_and_exchange_rate_fields_are_mandatory(self):
|
||||
# every field but target_exchange_rate is set, so that missing one raises
|
||||
doc = self.make_pe(
|
||||
paid_amount=100, received_amount=100, source_exchange_rate=1, target_exchange_rate=0
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_mandatory)
|
||||
|
||||
def test_received_amount_cannot_exceed_paid_in_same_currency(self):
|
||||
doc = self.make_pe(
|
||||
paid_from_account_currency="INR",
|
||||
paid_to_account_currency="INR",
|
||||
paid_amount=100,
|
||||
received_amount=150,
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_received_amount)
|
||||
# received <= paid is fine
|
||||
doc.received_amount = 50
|
||||
doc.validate_received_amount()
|
||||
|
||||
def test_duplicate_reference_rows_are_rejected(self):
|
||||
doc = self.make_pe()
|
||||
for _ in range(2):
|
||||
doc.append(
|
||||
"references",
|
||||
{"reference_doctype": "Sales Invoice", "reference_name": "SI-X", "allocated_amount": 100},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_entry)
|
||||
|
||||
def test_receive_from_customer_against_negative_outstanding_is_rejected(self):
|
||||
doc = self.make_pe(party_type="Customer", payment_type="Receive")
|
||||
doc.append(
|
||||
"references",
|
||||
{"reference_doctype": "Sales Invoice", "reference_name": "SI-Y", "allocated_amount": -100},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_payment_type_with_outstanding)
|
||||
|
||||
def test_bank_transaction_requires_a_reference_number(self):
|
||||
doc = self.make_pe(payment_type="Pay", paid_from="_Test Bank - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_transaction_reference)
|
||||
# supplying the reference details clears the requirement
|
||||
doc.reference_no = "TXN-1"
|
||||
doc.reference_date = "2026-06-15"
|
||||
doc.validate_transaction_reference()
|
||||
|
||||
@@ -75,10 +75,7 @@ class PaymentReconciliation(Document):
|
||||
self.accounting_dimension_filter_conditions = []
|
||||
self.ple_posting_date_filter = []
|
||||
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
|
||||
|
||||
@property
|
||||
def user_permissions(self):
|
||||
return get_user_permissions(frappe.session.user)
|
||||
self.user_permissions = get_user_permissions(frappe.session.user)
|
||||
|
||||
def load_from_db(self):
|
||||
# 'modified' attribute is required for `run_doc_method` to work properly.
|
||||
@@ -836,17 +833,10 @@ class PaymentReconciliation(Document):
|
||||
|
||||
|
||||
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
|
||||
allocated_amount_precision = get_field_precision(
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
|
||||
)
|
||||
for inv in dr_cr_notes:
|
||||
if (
|
||||
flt(
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
- inv.allocated_amount,
|
||||
allocated_amount_precision,
|
||||
)
|
||||
< 0
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
< inv.allocated_amount
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)
|
||||
|
||||
@@ -48,7 +48,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
sinv = create_sales_invoice(
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
posting_date=posting_date,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=self.item,
|
||||
@@ -2111,7 +2110,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
pr.reconcile()
|
||||
|
||||
si.reload()
|
||||
self.assertEqual(si.status, "Overdue")
|
||||
self.assertEqual(si.status, "Partly Paid")
|
||||
# check PR tool output post reconciliation
|
||||
self.assertEqual(len(pr.get("invoices")), 1)
|
||||
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
|
||||
@@ -2507,76 +2506,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_cr_note_split_across_invoices_floating_point_precision(self):
|
||||
"""Regression: when a credit note is split across multiple invoices, floating-point
|
||||
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
|
||||
|
||||
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
|
||||
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
|
||||
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
|
||||
"""
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
|
||||
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
|
||||
# (invoices are sorted by posting_date ascending, so si_a is processed first).
|
||||
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
|
||||
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
|
||||
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
|
||||
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
|
||||
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
|
||||
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
|
||||
|
||||
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
|
||||
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
|
||||
# by subtracting a decimal-valued payment from the integer total:
|
||||
# 100 - 91.55 = 8.45
|
||||
# 200 - 109.28 = 90.72
|
||||
# 100 - 27.43 = 72.57
|
||||
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.paid_amount = partial_paid
|
||||
pe.received_amount = partial_paid
|
||||
pe.references[0].allocated_amount = partial_paid
|
||||
pe.save().submit()
|
||||
|
||||
cr_note = self.create_sales_invoice(
|
||||
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
cr_note.is_return = 1
|
||||
cr_note = cr_note.save().submit()
|
||||
|
||||
pr = self.create_payment_reconciliation()
|
||||
# Widen date range so all three invoices (oldest is -2 days) are fetched
|
||||
pr.from_invoice_date = add_days(nowdate(), -2)
|
||||
pr.to_invoice_date = nowdate()
|
||||
pr.from_payment_date = nowdate()
|
||||
pr.to_payment_date = nowdate()
|
||||
|
||||
pr.get_unreconciled_entries()
|
||||
self.assertEqual(len(pr.invoices), 3)
|
||||
self.assertEqual(len(pr.payments), 1)
|
||||
|
||||
invoices = [x.as_dict() for x in pr.invoices]
|
||||
payments = [x.as_dict() for x in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
|
||||
self.assertEqual(len(pr.allocation), 3)
|
||||
last_row = pr.allocation[-1]
|
||||
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
|
||||
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
|
||||
|
||||
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
|
||||
pr.reconcile()
|
||||
|
||||
si_a.reload()
|
||||
si_b.reload()
|
||||
si_c.reload()
|
||||
self.assertEqual(si_a.outstanding_amount, 0)
|
||||
self.assertEqual(si_b.outstanding_amount, 0)
|
||||
# si_c is only partially settled: 72.57 - 50.83 = 21.74
|
||||
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
|
||||
|
||||
|
||||
def create_fiscal_year(company, year_start_date, year_end_date):
|
||||
fy_docname = frappe.db.exists(
|
||||
|
||||
@@ -542,7 +542,6 @@ class PaymentRequest(Document):
|
||||
bank_amount=bank_amount,
|
||||
created_from_payment_request=True,
|
||||
)
|
||||
payment_entry.set_missing_ref_details(force=True)
|
||||
|
||||
payment_entry.update(
|
||||
{
|
||||
@@ -719,7 +718,7 @@ class PaymentRequest(Document):
|
||||
row_number += TO_SKIP_NEW_ROW
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_payment_request(**args):
|
||||
"""Make payment request"""
|
||||
|
||||
|
||||
@@ -774,22 +774,6 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
pi.load_from_db()
|
||||
self.assertEqual(pr_2.grand_total, pi.outstanding_amount)
|
||||
|
||||
def test_payment_entry_reference_details_fetched_from_invoice(self):
|
||||
pi = make_purchase_invoice(currency="INR", qty=1, rate=94500)
|
||||
pi.submit()
|
||||
|
||||
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1)
|
||||
pr.grand_total = 94000
|
||||
pr.submit()
|
||||
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
|
||||
self.assertEqual(pe.references[0].reference_name, pi.name)
|
||||
self.assertEqual(pe.references[0].total_amount, pi.grand_total)
|
||||
self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount)
|
||||
self.assertEqual(pe.references[0].allocated_amount, 94000)
|
||||
self.assertEqual(pe.paid_amount, 94000)
|
||||
|
||||
def test_consider_journal_entry_and_return_invoice(self):
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
|
||||
@@ -41,17 +41,21 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(__("Ledger"), function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.period_start_date,
|
||||
to_date: frm.doc.period_end_date,
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
frm.add_custom_button(
|
||||
__("Ledger"),
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.period_start_date,
|
||||
to_date: frm.doc.period_end_date,
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -315,77 +315,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
repost_doc.posting_date = today()
|
||||
repost_doc.save()
|
||||
|
||||
def test_dimension_grouped_opening_balance_matches_gl_scan(self):
|
||||
"""
|
||||
A dimension-grouped Balance Sheet must produce identical per-dimension
|
||||
figures whether opening balances come from
|
||||
|
||||
- Account Closing Balance (the fast path) or
|
||||
- from a full GL scan (the fallback).
|
||||
"""
|
||||
from frappe.utils import add_days, getdate
|
||||
|
||||
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
|
||||
from erpnext.accounts.report.financial_statements import build_period_list
|
||||
|
||||
company = "Test PCV Company"
|
||||
cc1 = create_cost_center("Test Cost Center 1")
|
||||
cc2 = create_cost_center("Test Cost Center 2")
|
||||
|
||||
# Post to two cost centers, then close the year so balances land in Account Closing Balance.
|
||||
for amount, cost_center in ((400, cc1), (200, cc2)):
|
||||
jv = make_journal_entry(
|
||||
posting_date="2021-03-15",
|
||||
amount=amount,
|
||||
account1="Cash - TPC",
|
||||
account2="Sales - TPC",
|
||||
cost_center=cost_center,
|
||||
company=company,
|
||||
save=False,
|
||||
)
|
||||
jv.company = company
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
pcv = self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
report_date = add_days(getdate(pcv.period_end_date), 1)
|
||||
|
||||
report_filters = frappe._dict(
|
||||
company=company,
|
||||
period_start_date=report_date,
|
||||
period_end_date=report_date,
|
||||
periodicity="Yearly",
|
||||
filter_based_on="Date Range",
|
||||
accumulated_values=True,
|
||||
group_by_dimension="Cost Center",
|
||||
)
|
||||
|
||||
period_list = build_period_list(report_filters)
|
||||
period_keys = [p.key for p in period_list]
|
||||
|
||||
def key_for(cost_center):
|
||||
return next(p.key for p in period_list if p.dimension_value == cost_center)
|
||||
|
||||
def figures(data):
|
||||
return {
|
||||
row["account_name"]: {k: row.get(k) for k in period_keys}
|
||||
for row in data
|
||||
if row.get("account_name")
|
||||
}
|
||||
|
||||
# Fast path: opening balance sourced from Account Closing Balance.
|
||||
acb_figures = figures(execute(report_filters)[1])
|
||||
|
||||
# Fallback: force a full GL scan and expect the same numbers.
|
||||
with self.change_settings("Accounts Settings", {"ignore_account_closing_balance": 1}):
|
||||
gl_figures = figures(execute(report_filters)[1])
|
||||
|
||||
self.assertEqual(acb_figures, gl_figures)
|
||||
|
||||
# the fast path must carry per-dimension opening balances, not aggregates or zeros
|
||||
self.assertEqual(acb_figures["Cash"][key_for(cc1)], 400)
|
||||
self.assertEqual(acb_figures["Cash"][key_for(cc2)], 200)
|
||||
|
||||
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||
surplus_account = create_account()
|
||||
cost_center = create_cost_center("Test Cost Center 1")
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# Regression test for https://github.com/frappe/erpnext/issues/56501
|
||||
# AttributeError: 'POSInvoice' object has no attribute 'is_created_using_pos'
|
||||
# when calling reset_mode_of_payments on a draft POS Invoice.
|
||||
|
||||
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import (
|
||||
POSInvoiceTestMixin,
|
||||
create_pos_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
|
||||
|
||||
|
||||
class TestPOSInvoiceResetModeOfPayments(POSInvoiceTestMixin):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
def test_reset_mode_of_payments_does_not_raise_attribute_error(self):
|
||||
"""Calling reset_mode_of_payments on a draft POS Invoice must not raise
|
||||
AttributeError for the missing is_created_using_pos attribute.
|
||||
|
||||
update_multi_mode_option accesses doc.is_created_using_pos, which is a
|
||||
field on SalesInvoice but does not exist on POSInvoice, causing the error
|
||||
reported in #56501 when a user tries to edit a saved draft order.
|
||||
"""
|
||||
inv = create_pos_invoice(do_not_submit=True)
|
||||
|
||||
# This call must not raise AttributeError on the missing field.
|
||||
inv.reset_mode_of_payments()
|
||||
|
||||
# Payments should have been repopulated from the POS profile.
|
||||
self.assertTrue(len(inv.payments) > 0, "Payments should be populated after reset")
|
||||
@@ -40,7 +40,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<h4>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
</ul>
|
||||
</td></tr>
|
||||
<tr><td>
|
||||
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
|
||||
<h4><i class="fa fa-question-sign"></i>
|
||||
${__("How Pricing Rule is applied?")}
|
||||
</h4>
|
||||
<ol>
|
||||
|
||||
@@ -156,24 +156,6 @@ class PricingRule(Document):
|
||||
if len(values) != len(set(values)):
|
||||
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
|
||||
|
||||
if self.apply_on == "Item Code":
|
||||
self.validate_template_with_variant(values)
|
||||
|
||||
def validate_template_with_variant(self, item_codes):
|
||||
# throws if a template and its variant both exist in one rule
|
||||
variants = frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)},
|
||||
fields=["name", "variant_of"],
|
||||
)
|
||||
if variants:
|
||||
variant = variants[0]
|
||||
frappe.throw(
|
||||
_("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format(
|
||||
frappe.bold(variant.name), frappe.bold(variant.variant_of)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_mandatory(self):
|
||||
if self.has_priority and not self.priority:
|
||||
throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority"))
|
||||
|
||||
@@ -333,31 +333,6 @@ class TestPricingRule(ERPNextTestSuite):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("discount_percentage"), 17.5)
|
||||
|
||||
def test_pricing_rule_with_template_and_its_variant(self):
|
||||
if not frappe.db.exists("Item", "Test Variant PRT"):
|
||||
variant = frappe.new_doc("Item")
|
||||
variant.item_code = "Test Variant PRT"
|
||||
variant.item_name = "Test Variant PRT"
|
||||
variant.item_group = "_Test Item Group"
|
||||
variant.is_stock_item = 1
|
||||
variant.variant_of = "_Test Variant Item"
|
||||
variant.stock_uom = "_Test UOM"
|
||||
variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"})
|
||||
variant.insert()
|
||||
|
||||
rule = frappe.new_doc("Pricing Rule")
|
||||
rule.title = "_Test Pricing Rule Template Variant"
|
||||
rule.apply_on = "Item Code"
|
||||
rule.currency = "USD"
|
||||
rule.selling = 1
|
||||
rule.rate_or_discount = "Discount Percentage"
|
||||
rule.discount_percentage = 10
|
||||
rule.company = "_Test Company"
|
||||
rule.append("items", {"item_code": "_Test Variant Item"})
|
||||
rule.append("items", {"item_code": "Test Variant PRT"})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, rule.insert)
|
||||
|
||||
def test_pricing_rule_for_stock_qty(self):
|
||||
test_record = {
|
||||
"doctype": "Pricing Rule",
|
||||
|
||||
@@ -106,8 +106,6 @@ def get_pr_instance(doc: str):
|
||||
"party",
|
||||
"receivable_payable_account",
|
||||
"default_advance_account",
|
||||
"bank_cash_account",
|
||||
"cost_center",
|
||||
"from_invoice_date",
|
||||
"to_invoice_date",
|
||||
"from_payment_date",
|
||||
|
||||
@@ -1,73 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
|
||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||
get_pr_instance,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestProcessPaymentReconciliation(ERPNextTestSuite):
|
||||
"""Process Payment Reconciliation validates its accounts against the company,
|
||||
moves to Queued on submit, and hands its filters to a Payment Reconciliation run."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_ppr(self, **args):
|
||||
args = frappe._dict(args)
|
||||
doc = frappe.new_doc("Process Payment Reconciliation")
|
||||
doc.company = COMPANY
|
||||
doc.party_type = "Customer"
|
||||
doc.party = "_Test Customer"
|
||||
doc.receivable_payable_account = args.get("receivable_payable_account", "Debtors - _TC")
|
||||
doc.bank_cash_account = args.get("bank_cash_account")
|
||||
doc.from_invoice_date = args.get("from_invoice_date")
|
||||
doc.to_invoice_date = args.get("to_invoice_date")
|
||||
return doc
|
||||
|
||||
def other_company_account(self, **extra):
|
||||
filters = {"company": "_Test Company 1", "is_group": 0, **extra}
|
||||
account = frappe.db.get_value("Account", filters, "name")
|
||||
self.assertTrue(account, "need a matching account in _Test Company 1")
|
||||
return account
|
||||
|
||||
def test_receivable_account_must_belong_to_company(self):
|
||||
doc = self.make_ppr(receivable_payable_account=self.other_company_account(account_type="Receivable"))
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_bank_cash_account_must_belong_to_company(self):
|
||||
doc = self.make_ppr(bank_cash_account=self.other_company_account())
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_submit_sets_status_to_queued(self):
|
||||
doc = self.make_ppr()
|
||||
doc.insert()
|
||||
doc.submit()
|
||||
self.assertEqual(doc.status, "Queued")
|
||||
|
||||
def test_get_pr_instance_copies_filters_and_caps_limits(self):
|
||||
doc = self.make_ppr(from_invoice_date="2026-01-01", to_invoice_date="2026-06-30")
|
||||
doc.insert()
|
||||
|
||||
pr = get_pr_instance(doc.name)
|
||||
self.assertEqual(pr.company, COMPANY)
|
||||
self.assertEqual(pr.party, "_Test Customer")
|
||||
self.assertEqual(pr.receivable_payable_account, "Debtors - _TC")
|
||||
self.assertEqual(str(pr.from_invoice_date), "2026-01-01")
|
||||
# the tool run is capped so a single process can't fetch unbounded rows
|
||||
self.assertEqual(pr.invoice_limit, 1000)
|
||||
self.assertEqual(pr.payment_limit, 1000)
|
||||
|
||||
def test_get_pr_instance_copies_bank_cash_and_cost_center(self):
|
||||
doc = self.make_ppr(bank_cash_account="Cash - _TC")
|
||||
doc.cost_center = "_Test Cost Center - _TC"
|
||||
doc.insert()
|
||||
|
||||
pr = get_pr_instance(doc.name)
|
||||
self.assertEqual(pr.bank_cash_account, "Cash - _TC")
|
||||
self.assertEqual(pr.cost_center, "_Test Cost Center - _TC")
|
||||
pass
|
||||
|
||||
@@ -89,55 +89,50 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
cancel_pcv_processing(self.name)
|
||||
|
||||
|
||||
def initialize_parallel_threads(docname: str):
|
||||
threads = 4
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(threads)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
x.name,
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=x.name,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
initialize_parallel_threads(docname)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(4)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": x.processing_date,
|
||||
"parent": docname,
|
||||
"report_type": x.report_type,
|
||||
"parentfield": x.parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -255,11 +250,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(1)
|
||||
@@ -269,15 +264,15 @@ def schedule_next_date(docname: str):
|
||||
if not is_scheduler_inactive():
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
to_process[0].name,
|
||||
{
|
||||
"processing_date": to_process[0].processing_date,
|
||||
"parent": docname,
|
||||
"report_type": to_process[0].report_type,
|
||||
"parentfield": to_process[0].parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
@@ -285,7 +280,6 @@ def schedule_next_date(docname: str):
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=to_process[0].name,
|
||||
date=to_process[0].processing_date,
|
||||
report_type=to_process[0].report_type,
|
||||
parentfield=to_process[0].parentfield,
|
||||
@@ -450,11 +444,6 @@ def summarize_and_post_ledger_entries(docname):
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
@@ -540,10 +529,10 @@ def build_dimension_wise_balance_dict(gl_entries):
|
||||
return dimension_balances
|
||||
|
||||
|
||||
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
|
||||
def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
current_date_status = frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
)
|
||||
if current_date_status != "Running":
|
||||
@@ -591,20 +580,17 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie
|
||||
# save results
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"closing_balance",
|
||||
frappe.json.dumps(res),
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
"Completed",
|
||||
)
|
||||
# commit heavy computation before touching PPCV or PPCVD
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
# chain call
|
||||
schedule_next_date(docname)
|
||||
|
||||
@@ -48,27 +48,18 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
ppcv.save()
|
||||
return ppcv
|
||||
|
||||
def set_processing_date_status(self, row_name, status):
|
||||
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
"status",
|
||||
status,
|
||||
)
|
||||
|
||||
def get_row_name(self, ppcv_name, rpt_type, parentfield):
|
||||
return frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": ppcv_name, "report_type": rpt_type, "parentfield": parentfield},
|
||||
order_by="report_type, idx",
|
||||
pluck="name",
|
||||
limit=1,
|
||||
)[0]
|
||||
|
||||
def get_processing_date_closing_balance(self, row_name):
|
||||
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
|
||||
return frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
"closing_balance",
|
||||
)
|
||||
|
||||
@@ -106,10 +97,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
parentfield = "normal_balances"
|
||||
rpt_type = "Profit and Loss"
|
||||
# status has to be set to 'Running' for logic to run
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_pl = {
|
||||
"account": "Sales - _TC",
|
||||
@@ -125,10 +117,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
# Balance sheet balance
|
||||
rpt_type = "Balance Sheet"
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_bs = {
|
||||
"account": "Debtors - _TC",
|
||||
@@ -145,10 +138,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
# Opening balance
|
||||
parentfield = "z_opening_balances"
|
||||
rpt_type = "Balance Sheet"
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 2)
|
||||
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
|
||||
expected_opening_cash = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -24,10 +24,3 @@ class ProcessPeriodClosingVoucherDetail(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index(
|
||||
"Process Period Closing Voucher Detail",
|
||||
["parent", "status", "parentfield", "idx", "processing_date"],
|
||||
)
|
||||
|
||||
@@ -100,9 +100,9 @@ class ProcessStatementOfAccounts(Document):
|
||||
if not self.pdf_name:
|
||||
self.pdf_name = "{{ customer.customer_name }}"
|
||||
|
||||
validate_template(self.subject, restrict_globals=True)
|
||||
validate_template(self.body, restrict_globals=True)
|
||||
validate_template(self.pdf_name, restrict_globals=True)
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -421,6 +421,7 @@ def get_context(customer, doc):
|
||||
return {
|
||||
"doc": template_doc,
|
||||
"customer": frappe.get_doc("Customer", customer),
|
||||
"frappe": frappe.utils,
|
||||
}
|
||||
|
||||
|
||||
@@ -531,15 +532,15 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date:
|
||||
if report:
|
||||
for customer, report_pdf in report.items():
|
||||
context = get_context(customer, doc)
|
||||
filename = frappe.render_template(doc.pdf_name, context, restrict_globals=True)
|
||||
filename = frappe.render_template(doc.pdf_name, context)
|
||||
attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}]
|
||||
|
||||
recipients, cc = get_recipients_and_cc(customer, doc)
|
||||
if not recipients:
|
||||
continue
|
||||
|
||||
subject = frappe.render_template(doc.subject, context, restrict_globals=True)
|
||||
message = frappe.render_template(doc.body, context, restrict_globals=True)
|
||||
subject = frappe.render_template(doc.subject, context)
|
||||
message = frappe.render_template(doc.body, context)
|
||||
|
||||
if doc.sender:
|
||||
sender_email = frappe.db.get_value("Email Account", doc.sender, "email_id")
|
||||
|
||||
@@ -113,38 +113,3 @@ def create_process_soa(**args):
|
||||
process_soa.update(soa_dict)
|
||||
process_soa.save()
|
||||
return process_soa
|
||||
|
||||
|
||||
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
|
||||
"""validate() fills in default subject/body/pdf templates and enforces the
|
||||
basic constraints. Exercised on the document directly (no email/PDF flow)."""
|
||||
|
||||
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
|
||||
doc = frappe.new_doc("Process Statement Of Accounts")
|
||||
doc.report = report
|
||||
doc.company = "_Test Company"
|
||||
if with_customer:
|
||||
doc.append("customers", {"customer": "_Test Customer"})
|
||||
doc.update(overrides)
|
||||
return doc
|
||||
|
||||
def test_customers_are_required(self):
|
||||
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
|
||||
|
||||
def test_general_ledger_body_uses_a_date_range(self):
|
||||
doc = self.make_soa(report="General Ledger")
|
||||
doc.validate()
|
||||
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
|
||||
# subject and pdf name are also defaulted
|
||||
self.assertTrue(doc.subject)
|
||||
self.assertTrue(doc.pdf_name)
|
||||
|
||||
def test_receivable_body_uses_the_posting_date(self):
|
||||
doc = self.make_soa(report="Accounts Receivable")
|
||||
doc.validate()
|
||||
self.assertIn("until {{ doc.posting_date }}", doc.body)
|
||||
|
||||
def test_account_must_belong_to_company(self):
|
||||
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
|
||||
self.assertTrue(other, "need an account in _Test Company 1")
|
||||
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)
|
||||
|
||||
@@ -1,56 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.process_subscription.process_subscription import (
|
||||
create_subscription_process,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription.test_subscription import (
|
||||
create_parties,
|
||||
create_subscription,
|
||||
make_plans,
|
||||
reset_settings,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProcessSubscription(ERPNextTestSuite):
|
||||
"""Process Subscription is a batch driver: on submit it enqueues subscription.process_all
|
||||
for every non-cancelled Subscription (or just one when a subscription is named)."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
# mirror TestSubscription setup so subscriptions build against known settings
|
||||
make_plans()
|
||||
create_parties()
|
||||
reset_settings()
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
|
||||
|
||||
def enqueued_subscriptions(self, subscription=None):
|
||||
"""Submit a Process Subscription while capturing what gets enqueued."""
|
||||
calls = []
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
with patch("frappe.enqueue", side_effect=capture):
|
||||
create_subscription_process(subscription=subscription, posting_date="2026-06-15")
|
||||
|
||||
# each enqueue is handed a batch (list) of subscription names
|
||||
return [name for call in calls for name in call.get("subscription", [])]
|
||||
|
||||
def test_named_subscription_is_the_only_one_enqueued(self):
|
||||
sub = create_subscription(start_date="2026-01-01")
|
||||
self.assertEqual(self.enqueued_subscriptions(subscription=sub.name), [sub.name])
|
||||
|
||||
def test_cancelled_subscriptions_are_skipped(self):
|
||||
active = create_subscription(start_date="2026-01-01")
|
||||
cancelled = create_subscription(start_date="2026-01-01")
|
||||
cancelled.cancel_subscription()
|
||||
|
||||
enqueued = self.enqueued_subscriptions()
|
||||
self.assertIn(active.name, enqueued)
|
||||
self.assertNotIn(cancelled.name, enqueued)
|
||||
pass
|
||||
|
||||
@@ -1396,10 +1396,8 @@
|
||||
"fetch_from": "supplier.represents_company",
|
||||
"fieldname": "represents_company",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Represents Company",
|
||||
"options": "Company",
|
||||
"read_only": 1
|
||||
"options": "Company"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.update_stock && doc.is_internal_supplier",
|
||||
@@ -1694,7 +1692,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-12 23:54:21.263951",
|
||||
"modified": "2026-06-13 18:36:46.704623",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
@@ -130,6 +131,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
get_purchase_document_details,
|
||||
)
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
@@ -329,25 +331,33 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
# When Update Stock is disabled, this invoice has no stock impact: the linked
|
||||
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
|
||||
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
|
||||
# amount against the supplier - booking PPV again would double count it and leave
|
||||
# SRBNB partially uncleared.
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
handled = False
|
||||
if (
|
||||
item.item_code
|
||||
and item.item_code in stock_items
|
||||
and item.get("purchase_receipt")
|
||||
and not doc.is_return
|
||||
and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
|
||||
):
|
||||
handled = self.make_standard_cost_srbnb_split(
|
||||
gl_entries, item, expense_account, account_currency, base_amount
|
||||
)
|
||||
|
||||
if not handled:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
@@ -520,6 +530,95 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
},
|
||||
)
|
||||
|
||||
def make_standard_cost_srbnb_split(
|
||||
self, gl_entries, item, expense_account, account_currency, base_amount
|
||||
):
|
||||
"""For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
|
||||
value the receipt actually booked and post the (Net Amount - standard) difference to the
|
||||
Purchase Price Variance account. Returns False (caller falls back) if the receipt value
|
||||
can't be resolved."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
precision = item.precision("base_net_amount")
|
||||
standard_value = flt(self.get_pr_stock_value(item), precision)
|
||||
if not standard_value:
|
||||
return False
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": standard_value,
|
||||
"debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
variance = flt(base_amount - standard_value, precision)
|
||||
if variance:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": get_purchase_price_variance_account(item.item_code, doc.company),
|
||||
"against": doc.supplier,
|
||||
"debit": variance,
|
||||
"debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Purchase Price Variance"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def get_pr_stock_value(self, item):
|
||||
"""Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
|
||||
row is billing.
|
||||
|
||||
Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
|
||||
warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
|
||||
accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
|
||||
warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
|
||||
non-stock invoice).
|
||||
|
||||
The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
|
||||
clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
|
||||
pr_detail = frappe.db.get_value(
|
||||
"Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
|
||||
)
|
||||
if not pr_detail or not pr_detail.warehouse:
|
||||
return 0.0
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
result = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.stock_value_difference))
|
||||
.where(
|
||||
(sle.voucher_type == "Purchase Receipt")
|
||||
& (sle.voucher_no == item.purchase_receipt)
|
||||
& (sle.voucher_detail_no == item.pr_detail)
|
||||
& (sle.warehouse == pr_detail.warehouse)
|
||||
& (sle.is_cancelled == 0)
|
||||
)
|
||||
).run()
|
||||
accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
if not accepted_value or not flt(pr_detail.stock_qty):
|
||||
return accepted_value
|
||||
|
||||
# Pro-rate to the quantity being billed by this invoice row (handles partial billing).
|
||||
return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
|
||||
|
||||
def get_stock_variance_account(self, item):
|
||||
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
|
||||
Variance; for all other items it keeps the existing behaviour (default expense account)."""
|
||||
|
||||
@@ -1,55 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestRepostPaymentLedger(ERPNextTestSuite):
|
||||
"""Repost Payment Ledger auto-selects submitted vouchers on/after a cutoff date
|
||||
(unless rows are added manually) and queues them for a ledger rebuild."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_repost(self, **args):
|
||||
args = frappe._dict(args)
|
||||
doc = frappe.new_doc("Repost Payment Ledger")
|
||||
doc.company = COMPANY
|
||||
doc.posting_date = args.get("posting_date", "2026-06-01")
|
||||
doc.voucher_type = args.get("voucher_type", "Sales Invoice")
|
||||
doc.add_manually = args.get("add_manually", 0)
|
||||
return doc
|
||||
|
||||
def test_loads_submitted_vouchers_on_or_after_cutoff(self):
|
||||
after_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
|
||||
on_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-01", rate=100, qty=1)
|
||||
before_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
|
||||
|
||||
doc = self.make_repost(posting_date="2026-06-01", voucher_type="Sales Invoice")
|
||||
doc.save() # before_validate loads the vouchers and sets status
|
||||
|
||||
loaded = {v.voucher_no for v in doc.repost_vouchers}
|
||||
self.assertIn(after_cutoff.name, loaded)
|
||||
# the filter is >= so an invoice posted exactly on the cutoff is included
|
||||
self.assertIn(on_cutoff.name, loaded)
|
||||
self.assertNotIn(before_cutoff.name, loaded)
|
||||
self.assertEqual(doc.repost_status, "Queued")
|
||||
|
||||
def test_add_manually_preserves_user_rows(self):
|
||||
# manually add a BEFORE-cutoff invoice (which the filter would never load) while a
|
||||
# matching after-cutoff invoice also exists. If auto-loading wrongly ran it would
|
||||
# drop the manual row and pull the after-cutoff one, so this distinguishes the modes.
|
||||
manual_si = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
|
||||
create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
|
||||
|
||||
doc = self.make_repost(add_manually=1, posting_date="2026-06-01")
|
||||
doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": manual_si.name})
|
||||
doc.save()
|
||||
|
||||
rows = [(v.voucher_type, v.voucher_no) for v in doc.repost_vouchers]
|
||||
self.assertEqual(rows, [("Sales Invoice", manual_si.name)])
|
||||
pass
|
||||
|
||||
@@ -594,15 +594,7 @@ def create_dunning(
|
||||
if source.payment_schedule and len(source.payment_schedule) == 1:
|
||||
for row in target.overdue_payments:
|
||||
if row.payment_schedule == source.payment_schedule[0].name:
|
||||
# outstanding_amount is in the party account currency, but the Overdue Payment
|
||||
# row is in the invoice's transaction currency. When they differ, use the
|
||||
# payment schedule's own outstanding — it is kept in transaction currency and
|
||||
# updated as payments are allocated, so it stays correct even when the invoice
|
||||
# and its payments post at different exchange rates (#56006).
|
||||
if source.party_account_currency and source.party_account_currency != source.currency:
|
||||
row.outstanding = source.payment_schedule[0].outstanding
|
||||
else:
|
||||
row.outstanding = source.get("outstanding_amount")
|
||||
row.outstanding = source.get("outstanding_amount")
|
||||
|
||||
target.validate()
|
||||
|
||||
|
||||
@@ -33,11 +33,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
|
||||
|
||||
self.make_item_gl_entries(gl_entries)
|
||||
|
||||
disable_sdbnb_in_sr, is_sdbnb_enabled = frappe.get_cached_value(
|
||||
"Company", doc.company, ["disable_sdbnb_in_sr", "enable_stock_delivered_but_not_billed"]
|
||||
)
|
||||
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
|
||||
|
||||
if is_sdbnb_enabled and not (doc.is_return and disable_sdbnb_in_sr):
|
||||
if not (doc.is_return and disable_sdbnb_in_sr):
|
||||
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
|
||||
|
||||
self.make_precision_loss_gl_entry(gl_entries)
|
||||
|
||||
@@ -344,9 +344,7 @@ def update_multi_mode_option(doc, pos_profile) -> None:
|
||||
payment.account = payment_mode.default_account
|
||||
payment.type = payment_mode.type
|
||||
|
||||
# is_created_using_pos exists on Sales Invoice but not POS Invoice; use get() so this
|
||||
# shared helper doesn't raise AttributeError when called on a POS Invoice
|
||||
mop_refetched = bool(doc.payments) and not doc.get("is_created_using_pos")
|
||||
mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
|
||||
|
||||
doc.set("payments", [])
|
||||
invalid_modes = []
|
||||
|
||||
@@ -1576,14 +1576,14 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
|
||||
|
||||
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
|
||||
company = "_Test SDBNB Company"
|
||||
company = "_Test Company with perpetual inventory"
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
make_purchase_receipt(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - _TSDBNB",
|
||||
cost_center="Main - _TSDBNB",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=5,
|
||||
rate=100,
|
||||
)
|
||||
@@ -1591,13 +1591,13 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
dn = create_delivery_note(
|
||||
company=company,
|
||||
item_code="_Test FG Item",
|
||||
warehouse="Stores - _TSDBNB",
|
||||
cost_center="Main - _TSDBNB",
|
||||
warehouse="Stores - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
qty=2,
|
||||
rate=300,
|
||||
)
|
||||
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - _TSDBNB")
|
||||
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
|
||||
|
||||
si = make_sales_invoice(dn.name)
|
||||
si.insert()
|
||||
@@ -1609,9 +1609,9 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
sdbnb_credit = sum(
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - _TSDBNB"
|
||||
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
|
||||
)
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - _TSDBNB")
|
||||
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
|
||||
|
||||
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
|
||||
self.assertTrue(sdbnb_credit > 0)
|
||||
|
||||
@@ -121,65 +121,3 @@ class TestShareTransfer(ERPNextTestSuite):
|
||||
}
|
||||
)
|
||||
self.assertRaises(ShareDontExists, doc.insert)
|
||||
|
||||
|
||||
class TestShareTransferValidation(ERPNextTestSuite):
|
||||
"""basic_validations() enforces the transfer's internal consistency. Exercised
|
||||
directly (to_folio_no set to skip folio auto-naming) so no shareholder fixtures
|
||||
are needed - it only reasons about the document's own fields."""
|
||||
|
||||
def make_transfer(self, **overrides):
|
||||
doc = frappe.new_doc("Share Transfer")
|
||||
doc.update(
|
||||
{
|
||||
"transfer_type": "Transfer",
|
||||
"date": "2026-01-01",
|
||||
"from_shareholder": "SH-A",
|
||||
"to_shareholder": "SH-B",
|
||||
"to_folio_no": "1",
|
||||
"share_type": "Equity",
|
||||
"from_no": 1,
|
||||
"to_no": 100,
|
||||
"no_of_shares": 100,
|
||||
"rate": 10,
|
||||
"amount": 1000,
|
||||
"company": "_Test Company",
|
||||
"equity_or_liability_account": "Creditors - _TC",
|
||||
}
|
||||
)
|
||||
doc.update(overrides)
|
||||
return doc
|
||||
|
||||
def test_baseline_transfer_is_consistent(self):
|
||||
# the helper's defaults must pass, otherwise the negative cases prove nothing
|
||||
self.make_transfer().basic_validations()
|
||||
|
||||
def test_seller_and_buyer_must_differ(self):
|
||||
doc = self.make_transfer(to_shareholder="SH-A")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_share_count_must_match_the_number_range(self):
|
||||
# 1..100 is 100 shares, not 50
|
||||
doc = self.make_transfer(no_of_shares=50)
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_amount_must_equal_rate_times_shares(self):
|
||||
doc = self.make_transfer(amount=999) # 10 * 100 = 1000
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_amount_is_derived_when_left_blank(self):
|
||||
doc = self.make_transfer(amount=0)
|
||||
doc.basic_validations()
|
||||
self.assertEqual(doc.amount, 1000)
|
||||
|
||||
def test_equity_or_liability_account_is_required(self):
|
||||
doc = self.make_transfer(equity_or_liability_account=None)
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_issue_requires_a_to_shareholder(self):
|
||||
doc = self.make_transfer(transfer_type="Issue", to_shareholder="", asset_account="Cash - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_purchase_requires_a_from_shareholder(self):
|
||||
doc = self.make_transfer(transfer_type="Purchase", from_shareholder="", asset_account="Cash - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
@@ -79,9 +79,7 @@ def get_plan_rate(
|
||||
start_date = getdate(start_date)
|
||||
end_date = getdate(end_date)
|
||||
|
||||
delta = relativedelta.relativedelta(end_date, start_date)
|
||||
# include the years component so cross-year spans aren't under-counted
|
||||
no_of_months = delta.years * 12 + delta.months + 1
|
||||
no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
|
||||
cost = plan.cost * no_of_months
|
||||
|
||||
# Adjust cost if start or end date is not month start or end
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSubscriptionPlan(ERPNextTestSuite):
|
||||
"""Subscription Plan validates its interval and computes a rate. The Monthly
|
||||
Rate branch multiplies cost by the number of months in the billing window."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_plan(self, **args):
|
||||
args = frappe._dict(args)
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = f"_Test Plan {frappe.generate_hash(length=6)}"
|
||||
plan.item = args.item or "_Test Item"
|
||||
plan.currency = args.currency or "INR"
|
||||
plan.price_determination = args.price_determination
|
||||
plan.cost = args.cost or 0
|
||||
plan.billing_interval = args.billing_interval or "Month"
|
||||
plan.billing_interval_count = (
|
||||
args.billing_interval_count if args.billing_interval_count is not None else 1
|
||||
)
|
||||
return plan
|
||||
|
||||
def test_billing_interval_count_must_be_positive(self):
|
||||
plan = self.make_plan(price_determination="Fixed Rate", cost=100, billing_interval_count=0)
|
||||
self.assertRaises(frappe.ValidationError, plan.insert)
|
||||
|
||||
def test_fixed_rate_applies_prorate_factor(self):
|
||||
plan = self.make_plan(price_determination="Fixed Rate", cost=100)
|
||||
plan.insert()
|
||||
self.assertEqual(get_plan_rate(plan.name), 100)
|
||||
self.assertEqual(get_plan_rate(plan.name, prorate_factor=0.5), 50)
|
||||
|
||||
def test_monthly_rate_within_year(self):
|
||||
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
|
||||
plan.insert()
|
||||
# Jan 1 - Mar 31 is 3 whole months; month-aligned so proration is 0
|
||||
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2026-03-31")
|
||||
self.assertEqual(rate, 300)
|
||||
|
||||
def test_monthly_rate_across_year_boundary(self):
|
||||
# a 14-month span (Jan 2026 to Feb 2027) bills all 14 months, not just the
|
||||
# 2-month remainder that relativedelta.months alone would give
|
||||
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
|
||||
plan.insert()
|
||||
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2027-02-28")
|
||||
self.assertEqual(rate, 1400)
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") if doc.get(\"company\") else None %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") if doc.get(\"company\") else None %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") if doc.get(\"company\") else None %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"creation": "2026-05-15 15:21:48.255627",
|
||||
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead",
|
||||
"modified": "2026-07-12 21:11:44.765083",
|
||||
"modified": "2026-06-24 17:49:52.350750",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"letterhead-container\" style=\"width:100%\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" style=\"width:200px\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"creation": "2026-05-15 15:21:48.373815",
|
||||
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead - Grey",
|
||||
"modified": "2026-07-12 22:03:24.525672",
|
||||
"modified": "2026-06-24 18:23:05.120521",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead - Grey",
|
||||
|
||||
@@ -430,17 +430,6 @@ def get_party_account(
|
||||
Will first search in party (Customer / Supplier) record, if not found,
|
||||
will search in group (Customer Group / Supplier Group),
|
||||
finally will return default."""
|
||||
|
||||
def account_perm_check(account):
|
||||
ptype = "select" if frappe.only_has_select_perm("Account") else "read"
|
||||
if frappe.has_permission("Account", ptype, account):
|
||||
return
|
||||
|
||||
# Using custom message to prevent data leak in case of `apply_strict_permission` is enabled.
|
||||
frappe.throw(
|
||||
_("User don't have permissions to select/read this account."), exc=frappe.PermissionError
|
||||
)
|
||||
|
||||
if not party_type:
|
||||
frappe.throw(_("Party Type is mandatory"))
|
||||
if not company:
|
||||
@@ -451,51 +440,46 @@ def get_party_account(
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
else:
|
||||
return frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
account = frappe.db.get_value(
|
||||
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
|
||||
)
|
||||
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
|
||||
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
|
||||
account = frappe.db.get_value(
|
||||
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
|
||||
"Party Account",
|
||||
{"parenttype": party_group_doctype, "parent": group, "company": company},
|
||||
"account",
|
||||
)
|
||||
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
|
||||
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
|
||||
account = frappe.db.get_value(
|
||||
"Party Account",
|
||||
{"parenttype": party_group_doctype, "parent": group, "company": company},
|
||||
"account",
|
||||
)
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
default_account_name = (
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
default_account_name = (
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
existing_gle_currency = get_party_gle_currency(party_type, party, company)
|
||||
if existing_gle_currency:
|
||||
if account:
|
||||
account_currency = frappe.get_cached_value("Account", account, "account_currency")
|
||||
if (account and account_currency != existing_gle_currency) or not account:
|
||||
account = get_party_gle_account(party_type, party, company)
|
||||
|
||||
existing_gle_currency = get_party_gle_currency(party_type, party, company)
|
||||
if existing_gle_currency:
|
||||
if account:
|
||||
account_currency = frappe.get_cached_value("Account", account, "account_currency")
|
||||
if (account and account_currency != existing_gle_currency) or not account:
|
||||
account = get_party_gle_account(party_type, party, company)
|
||||
# get default account on the basis of party type
|
||||
if not account:
|
||||
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
|
||||
default_account_name = "default_" + account_type.lower() + "_account"
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
# get default account on the basis of party type
|
||||
if not account:
|
||||
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
|
||||
default_account_name = "default_" + account_type.lower() + "_account"
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
if account:
|
||||
account_perm_check(account)
|
||||
|
||||
if include_advance and party and party_type in ["Customer", "Supplier", "Student"]:
|
||||
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
|
||||
advance_account = get_party_advance_account(party_type, party, company)
|
||||
|
||||
if advance_account:
|
||||
account_perm_check(advance_account)
|
||||
return [account, advance_account]
|
||||
|
||||
return [account]
|
||||
else:
|
||||
return [account]
|
||||
|
||||
return account
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -174,17 +174,7 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
|
||||
get_datatable_options(options) {
|
||||
return Object.assign(options, {
|
||||
checkboxColumn: true,
|
||||
events: {
|
||||
onCheckRow: () => erpnext.accounts.toggle_create_pe_primary_action(frappe.query_report),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
after_refresh: function (report) {
|
||||
report.datatable?.rowmanager?.checkAll(false);
|
||||
report.page.clear_primary_action();
|
||||
return Object.assign(options, { checkboxColumn: true });
|
||||
},
|
||||
|
||||
onload: function (report) {
|
||||
@@ -196,27 +186,20 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
if (frappe.boot.sysdefaults.default_ageing_range) {
|
||||
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
|
||||
}
|
||||
|
||||
if (frappe.model.can_create("Payment Entry")) {
|
||||
report.page.add_inner_button(
|
||||
__("Create Payment Entries"),
|
||||
function () {
|
||||
erpnext.accounts.create_payment_entries_from_payable_report(report);
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
frappe.provide("erpnext.accounts");
|
||||
|
||||
erpnext.accounts.toggle_create_pe_primary_action = function (report) {
|
||||
if (!report || !report.datatable || !frappe.model.can_create("Payment Entry")) return;
|
||||
|
||||
const has_purchase_invoice = report.datatable.rowmanager
|
||||
.getCheckedRows()
|
||||
.some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice");
|
||||
|
||||
if (has_purchase_invoice) {
|
||||
report.page.set_primary_action(__("Create Payment Entries"), () =>
|
||||
erpnext.accounts.create_payment_entries_from_payable_report(report)
|
||||
);
|
||||
} else {
|
||||
report.page.clear_primary_action();
|
||||
}
|
||||
};
|
||||
|
||||
erpnext.accounts.create_payment_entries_from_payable_report = function (report) {
|
||||
const datatable = report.datatable;
|
||||
if (!datatable) return;
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.accounts_payable_summary.accounts_payable_summary import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAccountsPayableSummary(ERPNextTestSuite):
|
||||
"""Payable Summary is a thin wrapper over AccountsReceivableSummary with
|
||||
account_type=Payable; these tests lock the supplier-side output: invoiced,
|
||||
advance, paid, outstanding, ageing buckets and the optional GL-balance /
|
||||
future-payment columns."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.maxDiff = None
|
||||
self.company = "_Test Company"
|
||||
self.supplier = "_Test Supplier"
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"supplier": self.supplier,
|
||||
"posting_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
filters.update(overrides)
|
||||
return filters
|
||||
|
||||
def _make_invoice(self, rate=200):
|
||||
return make_purchase_invoice(
|
||||
company=self.company,
|
||||
supplier=self.supplier,
|
||||
qty=1,
|
||||
rate=rate,
|
||||
price_list_rate=rate,
|
||||
posting_date=today(),
|
||||
)
|
||||
|
||||
def _expected_row(self, pi, **overrides):
|
||||
supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group")
|
||||
row = {
|
||||
"party_type": "Supplier",
|
||||
"advance": 0,
|
||||
"party": self.supplier,
|
||||
"invoiced": 200.0,
|
||||
"paid": 0.0,
|
||||
"credit_note": 0.0,
|
||||
"outstanding": 200.0,
|
||||
"range1": 200.0,
|
||||
"range2": 0.0,
|
||||
"range3": 0.0,
|
||||
"range4": 0.0,
|
||||
"range5": 0.0,
|
||||
"total_due": 200.0,
|
||||
"future_amount": 0.0,
|
||||
"sales_person": [],
|
||||
"currency": pi.currency,
|
||||
"supplier_group": supplier_group,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
def test_01_payable_summary_output(self):
|
||||
"""Invoiced -> advance -> partial payment progression for a single supplier."""
|
||||
filters = self._filters()
|
||||
pi = self._make_invoice()
|
||||
|
||||
expected = self._expected_row(pi)
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# advance payment: pay 50 but allocate nothing against the invoice
|
||||
pe = get_payment_entry(pi.doctype, pi.name)
|
||||
pe.paid_amount = 50
|
||||
pe.references[0].allocated_amount = 0
|
||||
pe.save().submit()
|
||||
|
||||
expected.update({"advance": 50.0, "outstanding": 150.0, "range1": 150.0, "total_due": 150.0})
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# partial payment allocated against the invoice
|
||||
pe = get_payment_entry(pi.doctype, pi.name)
|
||||
pe.paid_amount = 125
|
||||
pe.references[0].allocated_amount = 125
|
||||
pe.save().submit()
|
||||
|
||||
expected.update(
|
||||
{"advance": 50.0, "paid": 125.0, "outstanding": 25.0, "range1": 25.0, "total_due": 25.0}
|
||||
)
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Buying Settings", {"supp_master_name": "Naming Series"})
|
||||
def test_02_gl_balance_and_future_payment_columns(self):
|
||||
"""Naming-series naming adds party_name; show_gl_balance / show_future_payments
|
||||
add their columns; a fully-paid invoice drops out of the report."""
|
||||
filters = self._filters()
|
||||
pi = self._make_invoice()
|
||||
|
||||
pe = get_payment_entry(pi.doctype, pi.name)
|
||||
pe.paid_amount = 150
|
||||
pe.references[0].allocated_amount = 150
|
||||
pe.save().submit()
|
||||
|
||||
expected = self._expected_row(
|
||||
pi,
|
||||
party_name=frappe.db.get_value("Supplier", self.supplier, "supplier_name"),
|
||||
paid=150.0,
|
||||
outstanding=50.0,
|
||||
range1=50.0,
|
||||
total_due=50.0,
|
||||
)
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# GL balance reconciliation columns
|
||||
filters.update({"show_gl_balance": True})
|
||||
expected.update({"gl_balance": 50.0, "diff": 0.0})
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# future payment columns
|
||||
filters.update({"show_future_payments": True})
|
||||
expected.update({"remaining_balance": 50.0})
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertDictEqual(rows[0], expected)
|
||||
|
||||
# clear the remaining balance -> supplier drops out of the summary entirely
|
||||
get_payment_entry(pi.doctype, pi.name).save().submit()
|
||||
rows = execute(filters)[1]
|
||||
self.assertEqual(len(rows), 0)
|
||||
@@ -264,12 +264,10 @@ class ReceivablePayableReport:
|
||||
|
||||
# Build and use a separate row for Employee Advances.
|
||||
# This allows Payments or Journals made against Emp Advance to be processed.
|
||||
if not row and (
|
||||
(ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances)
|
||||
or (
|
||||
ple.against_voucher_type == "Exchange Rate Revaluation"
|
||||
and self.filters.for_revaluation_journals
|
||||
)
|
||||
if (
|
||||
not row
|
||||
and ple.against_voucher_type == "Employee Advance"
|
||||
and self.filters.handle_employee_advances
|
||||
):
|
||||
_d = self.build_voucher_dict(ple)
|
||||
_d.voucher_type = ple.against_voucher_type
|
||||
|
||||
@@ -8,13 +8,6 @@ frappe.query_reports[BS_REPORT_NAME] = $.extend({}, erpnext.financial_statements
|
||||
erpnext.utils.add_dimensions(BS_REPORT_NAME, 10);
|
||||
|
||||
frappe.query_reports[BS_REPORT_NAME]["filters"].push(
|
||||
{
|
||||
fieldname: "group_by_dimension",
|
||||
label: __("Group by Dimension"),
|
||||
fieldtype: "Select",
|
||||
options: erpnext.financial_statements.get_accounting_dimension_options(),
|
||||
depends_on: "eval: !doc.report_template",
|
||||
},
|
||||
{
|
||||
fieldname: "report_template",
|
||||
label: __("Report Template"),
|
||||
|
||||
@@ -13,7 +13,6 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
accumulate_values_into_parents,
|
||||
add_total_row,
|
||||
build_period_list,
|
||||
calculate_values,
|
||||
compute_growth_view_data,
|
||||
filter_accounts,
|
||||
@@ -24,7 +23,7 @@ from erpnext.accounts.report.financial_statements import (
|
||||
get_columns,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
get_period_keys_for_total,
|
||||
get_period_list,
|
||||
prepare_data,
|
||||
)
|
||||
|
||||
@@ -33,10 +32,15 @@ def execute(filters=None):
|
||||
if filters and filters.report_template:
|
||||
return FinancialReportEngine().execute(filters)
|
||||
|
||||
period_list = build_period_list(filters)
|
||||
|
||||
if not period_list:
|
||||
return
|
||||
period_list = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
|
||||
filters.period_start_date = period_list[0]["year_start_date"]
|
||||
|
||||
@@ -75,13 +79,7 @@ def execute(filters=None):
|
||||
)
|
||||
|
||||
provisional_profit_loss, total_credit = get_provisional_profit_loss(
|
||||
asset,
|
||||
liability,
|
||||
equity,
|
||||
period_list,
|
||||
filters.company,
|
||||
currency,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
asset, liability, equity, period_list, filters.company, currency
|
||||
)
|
||||
|
||||
message, opening_balance = check_opening_balance(asset, liability, equity)
|
||||
@@ -111,11 +109,7 @@ def execute(filters=None):
|
||||
data.append(total_credit)
|
||||
|
||||
columns = get_columns(
|
||||
filters.periodicity,
|
||||
period_list,
|
||||
filters.accumulated_values,
|
||||
company=filters.company,
|
||||
selected_view=filters.get("selected_view"),
|
||||
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
|
||||
)
|
||||
|
||||
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
|
||||
@@ -131,18 +125,12 @@ def execute(filters=None):
|
||||
|
||||
|
||||
def get_provisional_profit_loss(
|
||||
asset,
|
||||
liability,
|
||||
equity,
|
||||
period_list,
|
||||
company,
|
||||
currency=None,
|
||||
consolidated=False,
|
||||
accumulated_values=False,
|
||||
asset, liability, equity, period_list, company, currency=None, consolidated=False
|
||||
):
|
||||
provisional_profit_loss = {}
|
||||
total_row = {}
|
||||
if asset:
|
||||
total = total_row_total = 0
|
||||
currency = currency or frappe.get_cached_value("Company", company, "default_currency")
|
||||
total_row = {
|
||||
"account_name": "'" + _("Total (Credit)") + "'",
|
||||
@@ -168,9 +156,11 @@ def get_provisional_profit_loss(
|
||||
if provisional_profit_loss[key]:
|
||||
has_value = True
|
||||
|
||||
total_keys = get_period_keys_for_total(period_list, accumulated_values, consolidated)
|
||||
provisional_profit_loss["total"] = flt(sum(provisional_profit_loss.get(k, 0.0) for k in total_keys))
|
||||
total_row["total"] = flt(sum(total_row.get(k, 0.0) for k in total_keys))
|
||||
total += flt(provisional_profit_loss[key])
|
||||
provisional_profit_loss["total"] = total
|
||||
|
||||
total_row_total += flt(total_row[key])
|
||||
total_row["total"] = total_row_total
|
||||
|
||||
if has_value:
|
||||
provisional_profit_loss.update(
|
||||
@@ -214,24 +204,23 @@ def get_report_summary(
|
||||
):
|
||||
net_asset, net_liability, net_equity, net_provisional_profit_loss = 0.0, 0.0, 0.0, 0.0
|
||||
|
||||
if filters.get("accumulated_values"):
|
||||
period_list = [period_list[-1]]
|
||||
|
||||
# from consolidated financial statement
|
||||
if filters.get("accumulated_in_group_company"):
|
||||
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
|
||||
keys = [period if consolidated else period.key for period in period_list]
|
||||
else:
|
||||
keys = get_period_keys_for_total(period_list, filters.accumulated_values, consolidated)
|
||||
|
||||
# get_data() output: [...account rows..., total_row, {}] → [-2] = total row, [-1] = blank separator
|
||||
# [-1] == {} guards against missing total row (e.g. empty liability/equity data)
|
||||
for key in keys:
|
||||
for period in period_list:
|
||||
key = period if consolidated else period.key
|
||||
if asset:
|
||||
net_asset += flt(asset[-2].get(key))
|
||||
net_asset += asset[-2].get(key)
|
||||
if liability and liability[-1] == {}:
|
||||
net_liability += flt(liability[-2].get(key))
|
||||
net_liability += liability[-2].get(key)
|
||||
if equity and equity[-1] == {}:
|
||||
net_equity += flt(equity[-2].get(key))
|
||||
net_equity += equity[-2].get(key)
|
||||
if provisional_profit_loss:
|
||||
net_provisional_profit_loss += flt(provisional_profit_loss.get(key))
|
||||
net_provisional_profit_loss += provisional_profit_loss.get(key)
|
||||
|
||||
return [
|
||||
{"value": net_asset, "label": _("Total Asset"), "datatype": "Currency", "currency": currency},
|
||||
@@ -294,7 +283,15 @@ def execute_snapshot_report(filters):
|
||||
if not (conn := get_latest_sync("GL Entry")):
|
||||
frappe.throw(_("Balance Sheet requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
|
||||
|
||||
period_list = build_period_list(filters)
|
||||
period_list = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
filters.period_start_date = period_list[0]["year_start_date"]
|
||||
|
||||
currency = filters.presentation_currency or frappe.get_cached_value(
|
||||
|
||||
@@ -5,7 +5,6 @@ import frappe
|
||||
from frappe.utils.data import today
|
||||
|
||||
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
|
||||
from erpnext.accounts.report.financial_statements import build_period_list, is_dimension_grouped
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company 6"
|
||||
@@ -107,79 +106,6 @@ class TestBalanceSheet(ERPNextTestSuite):
|
||||
self.assertIn("'Provisional Profit / Loss (Credit)'", name_and_total)
|
||||
self.assertEqual(name_and_total["'Provisional Profit / Loss (Credit)'"], 100)
|
||||
|
||||
def test_group_by_dimension(self):
|
||||
create_account("BS Dim Test Bank", f"Bank Accounts - {COMPANY_SHORT_NAME}", COMPANY)
|
||||
|
||||
cc1 = frappe.db.get_value("Cost Center", {"company": COMPANY, "is_group": 0}, "name")
|
||||
parent_cc = frappe.db.get_value("Cost Center", {"company": COMPANY, "is_group": 1}, "name")
|
||||
|
||||
cc2 = frappe.new_doc("Cost Center")
|
||||
cc2.cost_center_name = "BS Test CC 2"
|
||||
cc2.parent_cost_center = parent_cc
|
||||
cc2.company = COMPANY
|
||||
cc2.insert()
|
||||
|
||||
make_journal_entry(
|
||||
[
|
||||
dict(
|
||||
account_name="BS Dim Test Bank",
|
||||
debit_in_account_currency=300,
|
||||
credit_in_account_currency=0,
|
||||
cost_center=cc1,
|
||||
),
|
||||
dict(
|
||||
account_name="Capital Stock",
|
||||
debit_in_account_currency=0,
|
||||
credit_in_account_currency=300,
|
||||
cost_center=cc1,
|
||||
),
|
||||
]
|
||||
)
|
||||
make_journal_entry(
|
||||
[
|
||||
dict(
|
||||
account_name="BS Dim Test Bank",
|
||||
debit_in_account_currency=500,
|
||||
credit_in_account_currency=0,
|
||||
cost_center=cc2.name,
|
||||
),
|
||||
dict(
|
||||
account_name="Capital Stock",
|
||||
debit_in_account_currency=0,
|
||||
credit_in_account_currency=500,
|
||||
cost_center=cc2.name,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
company=COMPANY,
|
||||
period_start_date=today(),
|
||||
period_end_date=today(),
|
||||
periodicity="Yearly",
|
||||
filter_based_on="Date Range",
|
||||
accumulated_values=True,
|
||||
group_by_dimension="Cost Center",
|
||||
)
|
||||
period_list = build_period_list(filters)
|
||||
self.assertTrue(is_dimension_grouped(period_list))
|
||||
|
||||
def key_for(cost_center):
|
||||
return next(p.key for p in period_list if p.dimension_value == cost_center)
|
||||
|
||||
columns, data, *_ = execute(filters)
|
||||
|
||||
# each dimension group starts with exactly one flagged column (UI boundary marker)
|
||||
first_flags = [c["dimension_value"] for c in columns if c.get("is_first_in_dimension")]
|
||||
self.assertEqual(len(first_flags), len(set(first_flags)))
|
||||
self.assertLessEqual({cc1, cc2.name}, set(first_flags))
|
||||
|
||||
bank_row = next((r for r in data if r.get("account_name") == "BS Dim Test Bank"), None)
|
||||
self.assertIsNotNone(bank_row)
|
||||
self.assertEqual(bank_row[key_for(cc1)], 300)
|
||||
self.assertEqual(bank_row[key_for(cc2.name)], 500)
|
||||
self.assertEqual(bank_row["total"], 800)
|
||||
|
||||
|
||||
def make_journal_entry(rows):
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.bank_clearance_summary.bank_clearance_summary import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
BANK_ACCOUNT = "_Test Bank - _TC"
|
||||
|
||||
|
||||
class TestBankClearanceSummary(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"account": BANK_ACCOUNT,
|
||||
"company": "_Test Company",
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-12-31",
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def find_row(self, data, payment_entry):
|
||||
for row in data:
|
||||
if row[1] == payment_entry:
|
||||
return row
|
||||
return None
|
||||
|
||||
def test_uncleared_then_cleared_journal_entry(self):
|
||||
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 5000, submit=True, posting_date="2026-06-01")
|
||||
|
||||
# Uncleared: the bank row appears with the debit amount and no clearance date
|
||||
row = self.find_row(self.run_report(), je.name)
|
||||
self.assertIsNotNone(row, "Journal Entry not listed in Bank Clearance Summary")
|
||||
self.assertEqual(row[0], "Journal Entry")
|
||||
self.assertEqual(frappe.utils.getdate(row[2]), frappe.utils.getdate("2026-06-01"))
|
||||
self.assertIsNone(row[4]) # clearance_date empty -> uncleared
|
||||
self.assertEqual(row[5], "Sales - _TC") # against account
|
||||
self.assertEqual(row[6], 5000) # debit - credit on the bank account
|
||||
|
||||
# Cleared: set the clearance date on the Journal Entry and re-run
|
||||
frappe.db.set_value("Journal Entry", je.name, "clearance_date", "2026-06-05")
|
||||
|
||||
row = self.find_row(self.run_report(), je.name)
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(frappe.utils.getdate(row[4]), frappe.utils.getdate("2026-06-05"))
|
||||
self.assertEqual(row[6], 5000)
|
||||
|
||||
def test_date_filter_excludes_out_of_range_entries(self):
|
||||
je = make_journal_entry(BANK_ACCOUNT, "Sales - _TC", 3000, submit=True, posting_date="2026-06-10")
|
||||
|
||||
# Within range: present
|
||||
self.assertIsNotNone(self.find_row(self.run_report(), je.name))
|
||||
|
||||
# Window entirely after the posting date (from_date lower bound): excluded
|
||||
after = self.run_report(from_date="2026-07-01", to_date="2026-12-31")
|
||||
self.assertIsNone(self.find_row(after, je.name))
|
||||
|
||||
# Window ending before the posting date (to_date upper bound): excluded
|
||||
before = self.run_report(from_date="2026-01-01", to_date="2026-06-09")
|
||||
self.assertIsNone(self.find_row(before, je.name))
|
||||
@@ -31,7 +31,7 @@ def get_report_filters(report_filters):
|
||||
]
|
||||
|
||||
if report_filters.get("purchase_invoice"):
|
||||
filters.append(["Purchase Invoice", "name", "=", report_filters.get("purchase_invoice")])
|
||||
filters.append(["Purchase Invoice", "per_received", "in", [report_filters.get("purchase_invoice")]])
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.report.billed_items_to_be_received.billed_items_to_be_received import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBilledItemsToBeReceived(ERPNextTestSuite):
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"posting_date": today(),
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def get_rows_for(self, data, pi_name):
|
||||
return [row for row in data if row.get("name") == pi_name]
|
||||
|
||||
def test_billed_but_not_received_item_appears(self):
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(), pi.name)
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
row = rows[0]
|
||||
self.assertEqual(row.get("supplier"), "_Test Supplier")
|
||||
self.assertEqual(row.get("company"), "_Test Company")
|
||||
self.assertEqual(row.get("item_code"), "_Test Item")
|
||||
self.assertEqual(row.get("qty"), 5)
|
||||
self.assertEqual(row.get("received_qty"), 0)
|
||||
self.assertEqual(row.get("rate"), 200)
|
||||
self.assertEqual(row.get("amount"), 1000)
|
||||
|
||||
def test_stock_updating_invoice_is_excluded(self):
|
||||
"""update_stock=1 means the item is already received; it must not appear."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=1,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(), pi.name)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
def test_fully_received_invoice_drops_off(self):
|
||||
"""When per_received reaches 100 the invoice is fully received and drops off."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
# Present while nothing has been received.
|
||||
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 1)
|
||||
|
||||
frappe.db.set_value("Purchase Invoice", pi.name, "per_received", 100)
|
||||
|
||||
# Absent once fully received.
|
||||
self.assertEqual(len(self.get_rows_for(self.run_report(), pi.name)), 0)
|
||||
|
||||
def test_posting_date_upper_bound_filter(self):
|
||||
"""A PI posted after the filter's posting_date must be excluded."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier",
|
||||
item_code="_Test Item",
|
||||
qty=5,
|
||||
rate=200,
|
||||
update_stock=0,
|
||||
)
|
||||
|
||||
rows = self.get_rows_for(self.run_report(posting_date="2000-01-01"), pi.name)
|
||||
self.assertEqual(len(rows), 0)
|
||||
|
||||
def test_purchase_invoice_filter_scopes_to_that_invoice(self):
|
||||
"""The optional purchase_invoice filter must narrow to that invoice only."""
|
||||
pi = make_purchase_invoice(
|
||||
supplier="_Test Supplier", item_code="_Test Item", qty=5, rate=200, update_stock=0
|
||||
)
|
||||
other = make_purchase_invoice(
|
||||
supplier="_Test Supplier", item_code="_Test Item", qty=3, rate=200, update_stock=0
|
||||
)
|
||||
|
||||
names = {row.get("name") for row in self.run_report(purchase_invoice=pi.name)}
|
||||
self.assertEqual(names, {pi.name})
|
||||
self.assertNotIn(other.name, names)
|
||||
@@ -422,11 +422,6 @@ def build_comparison_chart_data(filters, columns, data):
|
||||
if not fieldname:
|
||||
continue
|
||||
|
||||
# skip the dimension column ("budget_against"), it only matches the
|
||||
# "budget_" prefix by coincidence and would shift the actual values by one
|
||||
if fieldname == "budget_against":
|
||||
continue
|
||||
|
||||
if fieldname.startswith("budget_"):
|
||||
budget_fields.append(fieldname)
|
||||
elif fieldname.startswith("actual_"):
|
||||
@@ -438,7 +433,7 @@ def build_comparison_chart_data(filters, columns, data):
|
||||
labels = [
|
||||
col["label"].replace("Budget", "").strip()
|
||||
for col in columns
|
||||
if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against"
|
||||
if col.get("fieldname", "").startswith("budget_")
|
||||
]
|
||||
|
||||
budget_values = [0] * len(budget_fields)
|
||||
|
||||
@@ -17,13 +17,6 @@ erpnext.utils.add_dimensions(CF_REPORT_NAME, 10);
|
||||
frappe.query_reports[CF_REPORT_NAME]["filters"].splice(8, 1);
|
||||
|
||||
frappe.query_reports[CF_REPORT_NAME]["filters"].push(
|
||||
{
|
||||
fieldname: "group_by_dimension",
|
||||
label: __("Group by Dimension"),
|
||||
fieldtype: "Select",
|
||||
options: erpnext.financial_statements.get_accounting_dimension_options(),
|
||||
depends_on: "eval: !doc.report_template",
|
||||
},
|
||||
{
|
||||
fieldname: "report_template",
|
||||
label: __("Report Template"),
|
||||
@@ -49,7 +42,6 @@ frappe.query_reports[CF_REPORT_NAME]["filters"].push(
|
||||
fieldname: "show_opening_and_closing_balance",
|
||||
label: __("Show Opening and Closing Balance"),
|
||||
fieldtype: "Check",
|
||||
depends_on: "eval:!doc.group_by_dimension",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -10,23 +10,17 @@ from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cstr, flt
|
||||
from pypika import Order
|
||||
from pypika.terms import Bracket, LiteralValue
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
get_dimension_with_children,
|
||||
)
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FinancialReportEngine,
|
||||
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
build_period_list,
|
||||
get_columns,
|
||||
get_cost_centers_with_children,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
is_dimension_grouped,
|
||||
get_period_list,
|
||||
set_gl_entries_by_account,
|
||||
)
|
||||
from erpnext.accounts.report.profit_and_loss_statement.profit_and_loss_statement import (
|
||||
@@ -39,10 +33,15 @@ def execute(filters=None):
|
||||
if filters and filters.report_template:
|
||||
return FinancialReportEngine().execute(filters)
|
||||
|
||||
period_list = build_period_list(filters)
|
||||
|
||||
if not period_list:
|
||||
return
|
||||
period_list = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
|
||||
cash_flow_sections = get_cash_flow_accounts()
|
||||
|
||||
@@ -68,13 +67,7 @@ def execute(filters=None):
|
||||
ignore_accumulated_values_for_fy=True,
|
||||
)
|
||||
|
||||
net_profit_loss = get_net_profit_loss(
|
||||
income,
|
||||
expense,
|
||||
period_list,
|
||||
filters.company,
|
||||
accumulated_values=bool(filters.accumulated_values),
|
||||
)
|
||||
net_profit_loss = get_net_profit_loss(income, expense, period_list, filters.company)
|
||||
|
||||
data = []
|
||||
summary_data = {}
|
||||
@@ -88,7 +81,6 @@ def execute(filters=None):
|
||||
"parent_section": None,
|
||||
"indent": 0.0,
|
||||
"section": cash_flow_section["section_header"],
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,16 +143,8 @@ def execute(filters=None):
|
||||
add_blank_row=False,
|
||||
)
|
||||
|
||||
if filters.show_opening_and_closing_balance and not is_dimension_grouped(period_list):
|
||||
if filters.show_opening_and_closing_balance:
|
||||
show_opening_and_closing_balance(data, period_list, company_currency, net_change_in_cash, filters)
|
||||
elif filters.show_opening_and_closing_balance:
|
||||
filters.show_opening_and_closing_balance = False
|
||||
|
||||
frappe.msgprint(
|
||||
indicator="orange",
|
||||
title=_("Not Supported"),
|
||||
msg=_("Opening and Closing balance is not supported for dimension grouped cash flow statement"),
|
||||
)
|
||||
|
||||
columns = get_columns(
|
||||
filters.periodicity,
|
||||
@@ -216,8 +200,6 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
|
||||
filters.start_date = start_date
|
||||
filters.end_date = period["to_date"]
|
||||
filters.account_type = account_type
|
||||
filters.dimension_field = period.get("dimension_field")
|
||||
filters.dimension_value = period.get("dimension_value")
|
||||
|
||||
amount = get_account_type_based_gl_data(company, filters)
|
||||
|
||||
@@ -234,71 +216,41 @@ def get_account_type_based_data(company, account_type, period_list, accumulated_
|
||||
def get_account_type_based_gl_data(company, filters=None):
|
||||
filters = frappe._dict(filters or {})
|
||||
|
||||
gl = frappe.qb.DocType("GL Entry")
|
||||
acc = frappe.qb.DocType("Account")
|
||||
gle = frappe.qb.DocType("GL Entry")
|
||||
account = frappe.qb.DocType("Account")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(gl)
|
||||
.select(Sum(gl.credit) - Sum(gl.debit))
|
||||
.where(gl.company == company)
|
||||
.where(gl.posting_date >= filters.start_date)
|
||||
.where(gl.posting_date <= filters.end_date)
|
||||
.where(gl.voucher_type != "Period Closing Voucher")
|
||||
frappe.qb.from_(gle)
|
||||
.select(Sum(gle.credit) - Sum(gle.debit))
|
||||
.where(
|
||||
gl.account.isin(
|
||||
frappe.qb.from_(acc)
|
||||
.select(acc.name)
|
||||
.where(acc.is_group == 0)
|
||||
.where(acc.company == company)
|
||||
.where(acc.account_type == filters.account_type)
|
||||
(gle.company == company)
|
||||
& (gle.posting_date >= filters.start_date)
|
||||
& (gle.posting_date <= filters.end_date)
|
||||
& (gle.voucher_type != "Period Closing Voucher")
|
||||
& gle.account.isin(
|
||||
frappe.qb.from_(account)
|
||||
.select(account.name)
|
||||
.where(account.account_type == filters.account_type)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# finance book
|
||||
if filters.include_default_book_entries:
|
||||
company_fb = frappe.get_cached_value("Company", company, "default_finance_book")
|
||||
query = query.where(
|
||||
(gl.finance_book.isin([cstr(filters.finance_book), cstr(company_fb), ""]))
|
||||
| (gl.finance_book.isnull())
|
||||
gle.finance_book.isin([filters.finance_book, company_fb, ""]) | gle.finance_book.isnull()
|
||||
)
|
||||
else:
|
||||
query = query.where(
|
||||
(gl.finance_book.isin([cstr(filters.finance_book), ""])) | (gl.finance_book.isnull())
|
||||
gle.finance_book.isin([cstr(filters.finance_book), ""]) | gle.finance_book.isnull()
|
||||
)
|
||||
|
||||
# cost center (with children)
|
||||
if filters.get("cost_center"):
|
||||
cost_centers = get_cost_centers_with_children(filters.cost_center)
|
||||
query = query.where(gl.cost_center.isin(cost_centers))
|
||||
query = query.where(gle.cost_center.isin(cost_centers))
|
||||
|
||||
# project
|
||||
if filters.get("project"):
|
||||
projects = filters.project
|
||||
if not isinstance(projects, list):
|
||||
projects = frappe.parse_json(projects)
|
||||
query = query.where(gl.project.isin(projects))
|
||||
|
||||
# per-period group-by-dimension filter (always a single exact value)
|
||||
if filters.get("dimension_field") and filters.get("dimension_value"):
|
||||
query = query.where(gl[filters.dimension_field] == filters.dimension_value)
|
||||
|
||||
# accounting dimension filters selected in the filter bar
|
||||
for dimension in get_accounting_dimensions(as_list=False):
|
||||
if filters.get(dimension.fieldname):
|
||||
values = filters[dimension.fieldname]
|
||||
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
|
||||
values = get_dimension_with_children(dimension.document_type, values)
|
||||
query = query.where(gl[dimension.fieldname].isin(values))
|
||||
|
||||
# apply permission filters
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
|
||||
if match_conditions := build_match_conditions("GL Entry"):
|
||||
query = query.where(Bracket(LiteralValue(match_conditions)))
|
||||
|
||||
result = query.run()
|
||||
return flt(result[0][0]) if result and result[0][0] else 0
|
||||
gl_sum = query.run()
|
||||
return gl_sum[0][0] if gl_sum and gl_sum[0][0] else 0
|
||||
|
||||
|
||||
def get_start_date(period, accumulated_values, company):
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate, today
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.report.cash_flow.cash_flow import execute
|
||||
from erpnext.accounts.report.financial_statements import build_period_list, is_dimension_grouped
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -69,45 +68,3 @@ class TestCashFlow(ERPNextTestSuite):
|
||||
make_journal_entry(asset_account, "Cash - _TC", 800, posting_date=today(), submit=True)
|
||||
|
||||
self.assertEqual(self.net_change_in_cash() - before, -800)
|
||||
|
||||
def test_group_by_dimension(self):
|
||||
"""Cash movements must land in their own cost center's column, not just the overall total."""
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
cc1, cc2 = "_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company,
|
||||
period_start_date=getdate(),
|
||||
period_end_date=getdate(),
|
||||
filter_based_on="Date Range",
|
||||
periodicity="Yearly",
|
||||
accumulated_values=False,
|
||||
group_by_dimension="Cost Center",
|
||||
)
|
||||
|
||||
period_list = build_period_list(filters)
|
||||
self.assertTrue(is_dimension_grouped(period_list))
|
||||
|
||||
def key_for(cost_center):
|
||||
return next(p.key for p in period_list if p.dimension_value == cost_center)
|
||||
|
||||
def net_change_row():
|
||||
rows = execute(filters)[1]
|
||||
return next((row for row in rows if row.get("section") == "'Net Change in Cash'"), {})
|
||||
|
||||
before = net_change_row()
|
||||
|
||||
# cash sales: 400 via cc1, 200 via cc2
|
||||
make_journal_entry(
|
||||
"Cash - _TC", "Sales - _TC", 400, cost_center=cc1, posting_date=today(), submit=True
|
||||
)
|
||||
make_journal_entry(
|
||||
"Cash - _TC", "Sales - _TC", 200, cost_center=cc2, posting_date=today(), submit=True
|
||||
)
|
||||
|
||||
after = net_change_row()
|
||||
|
||||
self.assertEqual(after.get(key_for(cc1), 0) - before.get(key_for(cc1), 0), 400)
|
||||
self.assertEqual(after.get(key_for(cc2), 0) - before.get(key_for(cc2), 0), 200)
|
||||
self.assertEqual(after.get("total", 0) - before.get("total", 0), 600)
|
||||
|
||||
@@ -192,15 +192,7 @@ def get_income_expense_data(companies, fiscal_year, filters):
|
||||
|
||||
expense = get_data(companies, "Expense", "Debit", fiscal_year, filters, True)
|
||||
|
||||
net_profit_loss = get_net_profit_loss(
|
||||
income,
|
||||
expense,
|
||||
companies,
|
||||
filters.company,
|
||||
company_currency,
|
||||
consolidated=True,
|
||||
accumulated_values=bool(filters.accumulated_values),
|
||||
)
|
||||
net_profit_loss = get_net_profit_loss(income, expense, companies, filters.company, company_currency, True)
|
||||
|
||||
return income, expense, net_profit_loss
|
||||
|
||||
@@ -590,12 +582,7 @@ def prepare_data(accounts, start_date, end_date, balance_must_be, companies, com
|
||||
total += flt(row[company])
|
||||
|
||||
row["has_value"] = has_value
|
||||
# when accumulating into the group company, that company's column already consolidates its
|
||||
# descendants, so summing every company column would double-count; use the group total directly.
|
||||
if filters.get("accumulated_in_group_company"):
|
||||
row["total"] = flt(row.get(filters.company, 0.0), 3)
|
||||
else:
|
||||
row["total"] = total
|
||||
row["total"] = total
|
||||
|
||||
data.append(row)
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, today
|
||||
|
||||
from erpnext.accounts.report.consolidated_financial_statement.consolidated_financial_statement import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
PARENT_COMPANY = "Parent Group Company India"
|
||||
CHILD_COMPANY = "Child Company India"
|
||||
|
||||
|
||||
class TestConsolidatedFinancialStatement(ERPNextTestSuite):
|
||||
"""Consolidation is exercised via the bootstrap group of companies
|
||||
(`Parent Group Company India` with child `Child Company India`). Income and
|
||||
expense posted in the child company must surface in the report that is run
|
||||
for the parent (group) company."""
|
||||
|
||||
def setUp(self):
|
||||
self.fiscal_year = get_fiscal_year(today(), company=PARENT_COMPANY)[0]
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": PARENT_COMPANY,
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"from_fiscal_year": self.fiscal_year,
|
||||
"to_fiscal_year": self.fiscal_year,
|
||||
"periodicity": "Yearly",
|
||||
"include_default_book_entries": 1,
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def post_journal_entry(self, debit_account, credit_account, amount):
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.posting_date = today()
|
||||
je.company = CHILD_COMPANY
|
||||
je.set(
|
||||
"accounts",
|
||||
[
|
||||
{"account": debit_account, "debit_in_account_currency": amount},
|
||||
{"account": credit_account, "credit_in_account_currency": amount},
|
||||
],
|
||||
)
|
||||
je.save()
|
||||
je.submit()
|
||||
return je
|
||||
|
||||
def get_row(self, data, account_name_fragment, last_match=False):
|
||||
"""Return the first (or last) row whose account_name contains the fragment.
|
||||
|
||||
Pass ``last_match=True`` to get the leaf/most-specific match when the fragment
|
||||
is also a prefix of a parent group account (parents precede children in tree order).
|
||||
"""
|
||||
found = None
|
||||
for row in data:
|
||||
if account_name_fragment in str(row.get("account_name") or ""):
|
||||
if not last_match:
|
||||
return row
|
||||
found = row
|
||||
return found
|
||||
|
||||
def test_profit_and_loss_reflects_child_company_income(self):
|
||||
amount = 7000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
|
||||
|
||||
self.assertTrue(data, "Report returned no rows")
|
||||
|
||||
# child's Sales account is mapped onto the parent chart (Sales - PGCI)
|
||||
sales_row = self.get_row(data, "Sales", last_match=True)
|
||||
self.assertIsNotNone(sales_row, "Sales row missing from consolidated P&L")
|
||||
# >= so a pre-existing Sales balance in the fiscal year doesn't make this brittle
|
||||
self.assertGreaterEqual(flt(sales_row.get(CHILD_COMPANY)), amount)
|
||||
|
||||
total_income_row = self.get_row(data, "Total Income (Credit)")
|
||||
self.assertIsNotNone(total_income_row, "Total Income row missing")
|
||||
self.assertGreaterEqual(flt(total_income_row.get("total")), amount)
|
||||
|
||||
def test_profit_and_loss_reflects_child_company_expense(self):
|
||||
amount = 3000
|
||||
self.post_journal_entry("Marketing Expenses - CCI", "Cash - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=0)
|
||||
|
||||
expense_row = self.get_row(data, "Marketing Expenses", last_match=True)
|
||||
self.assertIsNotNone(expense_row, "Marketing Expenses row missing from consolidated P&L")
|
||||
self.assertGreaterEqual(flt(expense_row.get(CHILD_COMPANY)), amount)
|
||||
|
||||
total_expense_row = self.get_row(data, "Total Expense (Debit)")
|
||||
self.assertIsNotNone(total_expense_row, "Total Expense row missing")
|
||||
self.assertGreaterEqual(flt(total_expense_row.get("total")), amount)
|
||||
|
||||
def test_accumulated_in_group_company_rolls_up_to_parent(self):
|
||||
"""With `accumulated_in_group_company`, the child's amount is also
|
||||
accumulated into the parent company column."""
|
||||
amount = 5000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Profit and Loss Statement", accumulated_in_group_company=1)
|
||||
|
||||
sales_row = self.get_row(data, "Sales", last_match=True)
|
||||
self.assertIsNotNone(sales_row)
|
||||
child_value = flt(sales_row.get(CHILD_COMPANY))
|
||||
self.assertGreaterEqual(child_value, amount)
|
||||
# parent column picks up the child value when accumulated
|
||||
self.assertEqual(flt(sales_row.get(PARENT_COMPANY)), child_value)
|
||||
# the total equals the consolidated (group) value, not the sum of parent + child
|
||||
# columns -- this is the regression guard for the double-count fix
|
||||
self.assertEqual(flt(sales_row.get("total")), child_value)
|
||||
|
||||
def test_balance_sheet_executes_and_returns_rows(self):
|
||||
# posting income leaves a balancing entry in the child's Cash (Asset) account
|
||||
amount = 4000
|
||||
self.post_journal_entry("Cash - CCI", "Sales - CCI", amount)
|
||||
|
||||
data = self.run_report(report="Balance Sheet", accumulated_in_group_company=0)
|
||||
|
||||
self.assertTrue(data, "Balance Sheet returned no rows")
|
||||
cash_row = self.get_row(data, "Cash")
|
||||
self.assertIsNotNone(cash_row, "Cash asset row missing from consolidated Balance Sheet")
|
||||
self.assertGreaterEqual(flt(cash_row.get(CHILD_COMPANY)), amount)
|
||||
@@ -1,94 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.custom_financial_statement.custom_financial_statement import execute
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCustomFinancialStatement(ERPNextTestSuite):
|
||||
"""The report renders a Financial Report Template through FinancialReportEngine.
|
||||
These tests exercise its own entry point: a template with an account-data row
|
||||
and a calculated row, and the guard that returns nothing without a template."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.company = "_Test Company"
|
||||
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
|
||||
self.cash_account = "Cash - _TC"
|
||||
|
||||
def _make_template(self):
|
||||
# rows filter by exact account name so the value is isolated from other data
|
||||
template_name = f"Test Custom FS {frappe.generate_hash()[:8]}"
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Financial Report Template",
|
||||
"template_name": template_name,
|
||||
"report_type": "Profit and Loss Statement",
|
||||
"rows": [
|
||||
{
|
||||
"reference_code": "EXP",
|
||||
"display_name": "Test Expense",
|
||||
"indentation_level": 0,
|
||||
"data_source": "Account Data",
|
||||
"balance_type": "Closing Balance",
|
||||
"calculation_formula": f'["name", "=", "{self.expense_account}"]',
|
||||
},
|
||||
{
|
||||
"reference_code": "EXP_X2",
|
||||
"display_name": "Expense Doubled",
|
||||
"indentation_level": 0,
|
||||
"data_source": "Calculated Amount",
|
||||
"calculation_formula": "EXP * 2",
|
||||
},
|
||||
],
|
||||
}
|
||||
).insert()
|
||||
|
||||
def _filters(self, template_name):
|
||||
return frappe._dict(
|
||||
{
|
||||
"company": self.company,
|
||||
"report_template": template_name,
|
||||
"from_fiscal_year": "2024",
|
||||
"to_fiscal_year": "2024",
|
||||
"period_start_date": "2024-01-01",
|
||||
"period_end_date": "2024-12-31",
|
||||
"filter_based_on": "Date Range",
|
||||
"periodicity": "Yearly",
|
||||
"accumulated_values": 0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_account_and_calculated_rows(self):
|
||||
make_journal_entry(
|
||||
self.expense_account,
|
||||
self.cash_account,
|
||||
2000,
|
||||
posting_date="2024-06-15",
|
||||
company=self.company,
|
||||
submit=True,
|
||||
)
|
||||
template = self._make_template()
|
||||
|
||||
columns, data = execute(self._filters(template.template_name))[:2]
|
||||
self.assertTrue(columns)
|
||||
|
||||
rows = {row.get("account_name"): row for row in data}
|
||||
self.assertIn("Test Expense", rows)
|
||||
self.assertIn("Expense Doubled", rows)
|
||||
|
||||
period_keys = rows["Test Expense"].get("_segment_info", {}).get("period_keys", [])
|
||||
self.assertTrue(period_keys, "expected at least one period key in _segment_info")
|
||||
period_key = period_keys[0]
|
||||
|
||||
# the account-data row picks up the posted expense; the calculated row doubles it
|
||||
self.assertEqual(flt(rows["Test Expense"][period_key]), 2000.0)
|
||||
self.assertEqual(flt(rows["Expense Doubled"][period_key]), 4000.0)
|
||||
|
||||
def test_no_template_returns_nothing(self):
|
||||
"""Without a report_template the report short-circuits and returns None."""
|
||||
self.assertIsNone(execute(frappe._dict({"company": self.company})))
|
||||
@@ -1,82 +0,0 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.accounts.report.dimension_wise_accounts_balance_report.dimension_wise_accounts_balance_report import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestDimensionWiseAccountsBalance(ERPNextTestSuite):
|
||||
"""Balances accounts one column per value of an accounting dimension (here
|
||||
Cost Center). Locks the two behaviours that matter: an entry lands in its
|
||||
own dimension column as debit - credit, and children roll up into parents."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.company = "_Test Company"
|
||||
self.expense_account = "_Test Account Cost for Goods Sold - _TC"
|
||||
self.cash_account = "Cash - _TC"
|
||||
|
||||
def _make_cost_center(self, name):
|
||||
full_name = f"{name} - _TC"
|
||||
if not frappe.db.exists("Cost Center", full_name):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": name,
|
||||
"parent_cost_center": "_Test Company - _TC",
|
||||
"company": self.company,
|
||||
"is_group": 0,
|
||||
}
|
||||
).insert()
|
||||
return full_name
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"company": self.company,
|
||||
"dimension": "Cost Center",
|
||||
"fiscal_year": get_fiscal_year(today(), company=self.company)[0],
|
||||
}
|
||||
)
|
||||
filters.update(overrides)
|
||||
return filters
|
||||
|
||||
def test_dimension_column_and_rollup(self):
|
||||
# a dedicated cost center isolates our column from any other posted data
|
||||
cost_center = self._make_cost_center("Test Dimension CC")
|
||||
make_journal_entry(
|
||||
self.expense_account,
|
||||
self.cash_account,
|
||||
300,
|
||||
cost_center=cost_center,
|
||||
posting_date=today(),
|
||||
submit=True,
|
||||
)
|
||||
|
||||
columns, data = execute(self._filters())
|
||||
column = frappe.scrub(cost_center)
|
||||
self.assertIn(column, [c["fieldname"] for c in columns])
|
||||
|
||||
rows = {row["account"]: row for row in data}
|
||||
|
||||
# the entry shows as debit - credit under its own dimension column
|
||||
self.assertEqual(rows[self.expense_account][column], 300.0)
|
||||
self.assertEqual(rows[self.cash_account][column], -300.0)
|
||||
|
||||
# and rolls up into each account's parent (isolated to our cost center)
|
||||
expense_parent = frappe.db.get_value("Account", self.expense_account, "parent_account")
|
||||
cash_parent = frappe.db.get_value("Account", self.cash_account, "parent_account")
|
||||
self.assertEqual(rows[expense_parent][column], 300.0)
|
||||
self.assertEqual(rows[cash_parent][column], -300.0)
|
||||
|
||||
def test_requires_fiscal_year(self):
|
||||
filters = self._filters()
|
||||
filters.pop("fiscal_year")
|
||||
self.assertRaises(frappe.ValidationError, execute, filters)
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import functools
|
||||
import math
|
||||
import re
|
||||
@@ -16,187 +15,12 @@ from pypika.terms import Bracket, ExistsCriterion, LiteralValue
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
get_dimension_fieldname,
|
||||
get_dimension_with_children,
|
||||
get_doctypes_with_dimensions,
|
||||
)
|
||||
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
|
||||
from erpnext.accounts.utils import get_fiscal_year, get_zero_cutoff
|
||||
|
||||
|
||||
def get_dimension_values(filters: frappe._dict) -> tuple[str | None, list]:
|
||||
"""
|
||||
Return (fieldname, [dimension_values]) for the chosen grouping dimension.
|
||||
|
||||
NOTE: Disabled dimensions values are not filtered out!
|
||||
"""
|
||||
if not filters.group_by_dimension:
|
||||
return None, []
|
||||
|
||||
dim_doctype = filters.group_by_dimension
|
||||
fieldname = get_dimension_fieldname(dim_doctype)
|
||||
|
||||
meta = frappe.get_meta(dim_doctype)
|
||||
is_tree = bool(meta.is_tree)
|
||||
|
||||
dim = frappe.qb.DocType(dim_doctype)
|
||||
query = frappe.qb.from_(dim).select(dim.name)
|
||||
|
||||
if is_tree and meta.has_field("is_group"):
|
||||
query = query.where(dim.is_group == 0)
|
||||
|
||||
if meta.has_field("company"):
|
||||
query = query.where(dim.company == filters.company)
|
||||
|
||||
# Self-filter: narrow to values the user picked for this same dimension.
|
||||
if selected := filters.get(fieldname):
|
||||
if isinstance(selected, str):
|
||||
selected = frappe.parse_json(selected)
|
||||
if is_tree:
|
||||
selected = get_dimension_with_children(dim_doctype, selected)
|
||||
query = query.where(dim.name.isin(selected))
|
||||
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
|
||||
if match_conditions := build_match_conditions(dim_doctype):
|
||||
query = query.where(Bracket(LiteralValue(match_conditions)))
|
||||
|
||||
# order by name
|
||||
query = query.orderby(dim.name)
|
||||
|
||||
return fieldname, query.run(pluck=True)
|
||||
|
||||
|
||||
def get_dimension_period_list(filters: frappe._dict) -> list[dict]:
|
||||
"""
|
||||
Return a period_list-shaped axis = cross-product of (dimension_value * time period).
|
||||
|
||||
Each cell is a `get_period_list` bucket plus dimension keys, e.g.:
|
||||
|
||||
```
|
||||
{
|
||||
"dimension_field": "cost_center",
|
||||
"dimension_value": "Main - ATD",
|
||||
"key": "main___atd_mar_2027",
|
||||
"label": "Main - ATD - 2026-2027",
|
||||
"period": "mar_2027",
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
"""
|
||||
fieldname, dimensions = get_dimension_values(filters)
|
||||
if not fieldname or not dimensions:
|
||||
return []
|
||||
|
||||
period_buckets = get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
accumulated_values=filters.accumulated_values,
|
||||
company=filters.company,
|
||||
)
|
||||
|
||||
if not period_buckets:
|
||||
return []
|
||||
|
||||
period_list = []
|
||||
|
||||
# Guard against rare collisions where two distinct dimension values
|
||||
# `frappe.scrub()` to the same key (e.g. "CC-A" and "CC A") and would
|
||||
# otherwise overwrite each other's column.
|
||||
used_keys = set()
|
||||
|
||||
for dimension in dimensions:
|
||||
dim_key_base = frappe.scrub(dimension)
|
||||
for period in period_buckets:
|
||||
key = f"{dim_key_base}_{period.key}"
|
||||
|
||||
if key in used_keys:
|
||||
key = f"{key}_{len(used_keys)}"
|
||||
used_keys.add(key)
|
||||
|
||||
cell = frappe._dict(period)
|
||||
cell.update(
|
||||
{
|
||||
"key": key,
|
||||
"label": f"{dimension} - {period.label}",
|
||||
"dimension_field": fieldname,
|
||||
"dimension_value": dimension,
|
||||
"period": period.key,
|
||||
}
|
||||
)
|
||||
period_list.append(cell)
|
||||
|
||||
return period_list
|
||||
|
||||
|
||||
def build_period_list(filters: frappe._dict) -> list[dict]:
|
||||
"""
|
||||
Build the report `period_list` from filters.
|
||||
|
||||
- If `group_by_dimension` is set, returns a dimension * period cross-product via `get_dimension_period_list`.
|
||||
- Otherwise, returns plain time buckets via `get_period_list`.
|
||||
"""
|
||||
if filters.group_by_dimension and not filters.report_template:
|
||||
return get_dimension_period_list(filters)
|
||||
|
||||
return get_period_list(
|
||||
filters.from_fiscal_year,
|
||||
filters.to_fiscal_year,
|
||||
filters.period_start_date,
|
||||
filters.period_end_date,
|
||||
filters.filter_based_on,
|
||||
filters.periodicity,
|
||||
company=filters.company,
|
||||
)
|
||||
|
||||
|
||||
def is_dimension_grouped(period_list: list[dict]) -> bool:
|
||||
"""
|
||||
Return True if period_list contains dimension-grouped periods.
|
||||
"""
|
||||
if not period_list or not isinstance(period_list, list):
|
||||
return False
|
||||
|
||||
return bool(period_list[0].get("dimension_field"))
|
||||
|
||||
|
||||
def get_period_keys_for_total(
|
||||
period_list: list[dict],
|
||||
accumulated_values: bool,
|
||||
consolidated: bool = False,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Return the period keys whose values should be summed for the row-level
|
||||
`Total` column / report-summary cards.
|
||||
|
||||
- Group by Dimension + accumulated: each dimension's last period
|
||||
- Accumulated: only the last period
|
||||
- Not accumulated: all periods (sum of independent period activity)
|
||||
- Consolidated: list of period keys is the same as the period_list
|
||||
- In case of consolidated reports
|
||||
"""
|
||||
if not period_list:
|
||||
return []
|
||||
|
||||
if consolidated:
|
||||
return list(period_list)
|
||||
|
||||
if is_dimension_grouped(period_list) and accumulated_values:
|
||||
return list({period.dimension_value: period.key for period in period_list}.values())
|
||||
|
||||
# when 'accumulated_values' is enabled, periods have running balance.
|
||||
# so, last period will have the net amount.
|
||||
if accumulated_values:
|
||||
return [period_list[-1].key]
|
||||
|
||||
return [period.key for period in period_list]
|
||||
|
||||
|
||||
def get_period_list(
|
||||
from_fiscal_year,
|
||||
to_fiscal_year,
|
||||
@@ -209,19 +33,18 @@ def get_period_list(
|
||||
reset_period_on_fy_change=True,
|
||||
ignore_fiscal_year=False,
|
||||
):
|
||||
"""
|
||||
Generate a list of time buckets between the provided from/to fiscal year or date range,
|
||||
based on the periodicity (Yearly, Half-Yearly, Quarterly, Monthly).
|
||||
"""
|
||||
"""Get a list of dict {"from_date": from_date, "to_date": to_date, "key": key, "label": label}
|
||||
Periodicity can be (Yearly, Quarterly, Monthly)"""
|
||||
|
||||
# Resolve the report's overall date range (with validation).
|
||||
if filter_based_on == "Fiscal Year":
|
||||
fy_data = get_fiscal_year_data(from_fiscal_year, to_fiscal_year)
|
||||
validate_fiscal_year(fy_data, from_fiscal_year, to_fiscal_year)
|
||||
year_start_date, year_end_date = getdate(fy_data.year_start_date), getdate(fy_data.year_end_date)
|
||||
fiscal_year = get_fiscal_year_data(from_fiscal_year, to_fiscal_year)
|
||||
validate_fiscal_year(fiscal_year, from_fiscal_year, to_fiscal_year)
|
||||
year_start_date = getdate(fiscal_year.year_start_date)
|
||||
year_end_date = getdate(fiscal_year.year_end_date)
|
||||
else:
|
||||
validate_dates(period_start_date, period_end_date)
|
||||
year_start_date, year_end_date = getdate(period_start_date), getdate(period_end_date)
|
||||
year_start_date = getdate(period_start_date)
|
||||
year_end_date = getdate(period_end_date)
|
||||
|
||||
months_to_add = {"Yearly": 12, "Half-Yearly": 6, "Quarterly": 3, "Monthly": 1}[periodicity]
|
||||
|
||||
@@ -410,8 +233,6 @@ def calculate_values(
|
||||
accumulated_values,
|
||||
ignore_accumulated_values_for_fy,
|
||||
):
|
||||
grouped_by_dimension = is_dimension_grouped(period_list)
|
||||
|
||||
for entries in gl_entries_by_account.values():
|
||||
for entry in entries:
|
||||
d = accounts_by_name.get(entry.account)
|
||||
@@ -422,8 +243,7 @@ def calculate_values(
|
||||
raise_exception=1,
|
||||
)
|
||||
for period in period_list:
|
||||
if grouped_by_dimension and entry.get(period.dimension_field) != period.dimension_value:
|
||||
continue
|
||||
# check if posting date is within the period
|
||||
|
||||
if entry.posting_date <= period.to_date:
|
||||
if (accumulated_values or entry.posting_date >= period.from_date) and (
|
||||
@@ -432,8 +252,7 @@ def calculate_values(
|
||||
):
|
||||
d[period.key] = d.get(period.key, 0.0) + flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
# Balance Sheet only: track pre-FY entries as opening_balance (no per-dimension breakdown possible).
|
||||
if not grouped_by_dimension and entry.posting_date < period_list[0].year_start_date:
|
||||
if entry.posting_date < period_list[0].year_start_date:
|
||||
d["opening_balance"] = d.get("opening_balance", 0.0) + flt(entry.debit) - flt(entry.credit)
|
||||
|
||||
|
||||
@@ -455,11 +274,11 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
|
||||
data = []
|
||||
year_start_date = period_list[0]["year_start_date"].strftime("%Y-%m-%d")
|
||||
year_end_date = period_list[-1]["year_end_date"].strftime("%Y-%m-%d")
|
||||
total_keys = get_period_keys_for_total(period_list, accumulated_values)
|
||||
|
||||
for d in accounts:
|
||||
# add to output
|
||||
has_value = False
|
||||
total = 0
|
||||
row = frappe._dict(
|
||||
{
|
||||
"account": _(d.name),
|
||||
@@ -484,14 +303,21 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
|
||||
# change sign based on Debit or Credit, since calculation is done using (debit - credit)
|
||||
d[period.key] *= -1
|
||||
|
||||
row[period.key] = flt(d.get(period.key, 0), 3)
|
||||
row[period.key] = flt(d.get(period.key, 0.0), 3)
|
||||
|
||||
if abs(row[period.key]) >= get_zero_cutoff(company_currency):
|
||||
# ignore zero values
|
||||
has_value = True
|
||||
total += flt(row[period.key])
|
||||
|
||||
row["has_value"] = has_value
|
||||
row["total"] = flt(sum(row.get(k, 0) for k in total_keys), 3)
|
||||
if accumulated_values:
|
||||
# when 'accumulated_values' is enabled, periods have running balance.
|
||||
# so, last period will have the net amount.
|
||||
row["has_value"] = has_value
|
||||
row["total"] = flt(d.get(period_list[-1].key, 0.0), 3)
|
||||
else:
|
||||
row["has_value"] = has_value
|
||||
row["total"] = total
|
||||
data.append(row)
|
||||
|
||||
return data
|
||||
@@ -721,10 +547,6 @@ def get_accounting_entries(
|
||||
.where(gl_entry.company == filters.company)
|
||||
)
|
||||
|
||||
if filters.group_by_dimension and doctype in get_doctypes_with_dimensions() and not group_by_account:
|
||||
dimension_field = get_dimension_fieldname(filters.group_by_dimension)
|
||||
query = query.select(gl_entry[dimension_field])
|
||||
|
||||
if not ignore_reporting_currency:
|
||||
query = query.select(
|
||||
gl_entry.debit_in_reporting_currency
|
||||
@@ -865,14 +687,7 @@ def get_cost_centers_with_children(cost_centers):
|
||||
return list(set(all_cost_centers))
|
||||
|
||||
|
||||
def get_columns(
|
||||
periodicity,
|
||||
period_list,
|
||||
accumulated_values=1,
|
||||
company=None,
|
||||
cash_flow=False,
|
||||
selected_view="Report",
|
||||
):
|
||||
def get_columns(periodicity, period_list, accumulated_values=1, company=None, cash_flow=False):
|
||||
columns = [
|
||||
{
|
||||
"fieldname": "account" if not cash_flow else "section",
|
||||
@@ -882,7 +697,6 @@ def get_columns(
|
||||
"width": 300,
|
||||
}
|
||||
]
|
||||
|
||||
if not cash_flow:
|
||||
columns.extend(
|
||||
[
|
||||
@@ -902,7 +716,6 @@ def get_columns(
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
if company:
|
||||
columns.append(
|
||||
{
|
||||
@@ -913,40 +726,27 @@ def get_columns(
|
||||
"hidden": 1,
|
||||
}
|
||||
)
|
||||
|
||||
seen_dim_values = set()
|
||||
for period in period_list:
|
||||
col = {
|
||||
"fieldname": period.key,
|
||||
"label": period.label,
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"width": 150,
|
||||
}
|
||||
|
||||
if dim_value := period.get("dimension_value"):
|
||||
# used to identify cross-dimension boundaries
|
||||
col["dimension_value"] = dim_value
|
||||
|
||||
# to handle special view (Growth/Margin) formatting in UI.
|
||||
if dim_value not in seen_dim_values:
|
||||
seen_dim_values.add(dim_value)
|
||||
col["is_first_in_dimension"] = True
|
||||
|
||||
columns.append(col)
|
||||
|
||||
if selected_view not in ("Growth", "Margin") and (
|
||||
is_dimension_grouped(period_list) or (periodicity != "Yearly" and not accumulated_values)
|
||||
):
|
||||
columns.append(
|
||||
{
|
||||
"fieldname": "total",
|
||||
"label": _("Total"),
|
||||
"fieldname": period.key,
|
||||
"label": period.label,
|
||||
"fieldtype": "Currency",
|
||||
"width": 150,
|
||||
"options": "currency",
|
||||
"width": 150,
|
||||
}
|
||||
)
|
||||
if periodicity != "Yearly":
|
||||
if not accumulated_values:
|
||||
columns.append(
|
||||
{
|
||||
"fieldname": "total",
|
||||
"label": _("Total"),
|
||||
"fieldtype": "Currency",
|
||||
"width": 150,
|
||||
"options": "currency",
|
||||
}
|
||||
)
|
||||
|
||||
return columns
|
||||
|
||||
@@ -968,10 +768,6 @@ def compute_growth_view_data(data, columns):
|
||||
continue
|
||||
|
||||
for column_idx in range(1, len(columns)):
|
||||
# No growth comparison across dimension boundaries
|
||||
if columns[column_idx - 1].get("dimension_value") != columns[column_idx].get("dimension_value"):
|
||||
continue
|
||||
|
||||
previous_period_key = columns[column_idx - 1].get("key")
|
||||
current_period_key = columns[column_idx].get("key")
|
||||
current_period_value = data_copy[row_idx].get(current_period_key)
|
||||
@@ -993,10 +789,13 @@ def compute_growth_view_data(data, columns):
|
||||
data[row_idx][current_period_key] = growth_percent
|
||||
|
||||
|
||||
def compute_margin_view_data(data, columns):
|
||||
def compute_margin_view_data(data, columns, accumulated_values):
|
||||
if not columns:
|
||||
return
|
||||
|
||||
if not accumulated_values:
|
||||
columns.append({"key": "total"})
|
||||
|
||||
data_copy = copy.deepcopy(data)
|
||||
|
||||
base_row = None
|
||||
|
||||
@@ -227,7 +227,6 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
)
|
||||
if total_base_amount
|
||||
else 0,
|
||||
"currency": filters.currency,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -270,7 +269,6 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
|
||||
"buying_amount": total_buying_amount,
|
||||
"gross_profit": total_gross_profit,
|
||||
"gross_profit_percent": flt(gross_profit_percent, currency_precision),
|
||||
"currency": filters.currency,
|
||||
}
|
||||
|
||||
total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]]
|
||||
|
||||
@@ -21,12 +21,7 @@ def execute(filters=None):
|
||||
entries = get_entries(filters)
|
||||
invoice_details = get_invoice_posting_date_map(filters)
|
||||
|
||||
# Only four range columns are defined (range1-range4, the last being "90 Above").
|
||||
# Three thresholds yield exactly four buckets, so payments more than 90 days after
|
||||
# the invoice land in range4 instead of an unread range5.
|
||||
report_filters = frappe._dict(filters)
|
||||
report_filters.range = "30, 60, 90"
|
||||
report = ReceivablePayableReport(report_filters)
|
||||
report = ReceivablePayableReport(filters)
|
||||
|
||||
data = []
|
||||
for d in entries:
|
||||
|
||||
@@ -32,15 +32,15 @@ class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
|
||||
}
|
||||
)
|
||||
filters.update(extra)
|
||||
columns, data = execute(filters)
|
||||
fieldnames = [c["fieldname"] for c in columns]
|
||||
# Map each positional row to a dict keyed by column fieldname so assertions
|
||||
# stay correct even if a column is inserted or reordered.
|
||||
return columns, [dict(zip(fieldnames, row, strict=False)) for row in data]
|
||||
return execute(filters)
|
||||
|
||||
def find_payment_row(self, data, payment_name):
|
||||
# Row shape (positional): payment_document, payment_entry(voucher_no),
|
||||
# party_type, party, posting_date, invoice(against_voucher_no),
|
||||
# invoice_posting_date, due_date, amount, remarks, age,
|
||||
# range1, range2, range3, range4, [delay_in_payment]
|
||||
for row in data:
|
||||
if row["payment_entry"] == payment_name:
|
||||
if row[1] == payment_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
@@ -57,60 +57,42 @@ class TestPaymentPeriodBasedOnInvoiceDate(ERPNextTestSuite):
|
||||
invoice = create_sales_invoice(customer="_Test Customer", rate=1000, posting_date="2026-06-01")
|
||||
payment = self.pay_invoice(invoice, "2026-06-20")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["party_type"], "Customer")
|
||||
self.assertEqual(row["posting_date"], getdate("2026-06-20"))
|
||||
self.assertEqual(row["invoice"], invoice.name)
|
||||
self.assertEqual(row["invoice_posting_date"], getdate("2026-06-01"))
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 19) # age = payment date - invoice date
|
||||
# Positional assertions on the row shape.
|
||||
self.assertEqual(row[2], "Customer")
|
||||
self.assertEqual(row[4], getdate("2026-06-20")) # payment posting date
|
||||
self.assertEqual(row[5], invoice.name) # against invoice
|
||||
self.assertEqual(row[6], getdate("2026-06-01")) # invoice posting date
|
||||
self.assertEqual(row[8], 1000) # amount
|
||||
self.assertEqual(row[10], 19) # age = payment date - invoice date
|
||||
|
||||
# Buckets: 0-30 filled, others empty.
|
||||
self.assertEqual(row["range1"], 1000) # 0-30
|
||||
self.assertEqual(row["range2"], 0) # 30-60
|
||||
self.assertEqual(row["range3"], 0) # 60-90
|
||||
self.assertEqual(row["range4"], 0) # 90 Above
|
||||
self.assertEqual(row[11], 1000) # range1 (0-30)
|
||||
self.assertEqual(row[12], 0) # range2 (30-60)
|
||||
self.assertEqual(row[13], 0) # range3 (60-90)
|
||||
self.assertEqual(row[14], 0) # range4 (90 Above)
|
||||
|
||||
def test_paid_amount_lands_in_30_60_bucket(self):
|
||||
# invoice 2026-06-01, paid 2026-07-16 -> 45 days after -> 30-60 bucket
|
||||
invoice = create_sales_invoice(customer="_Test Customer 1", rate=1000, posting_date="2026-06-01")
|
||||
payment = self.pay_invoice(invoice, "2026-07-16")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 45)
|
||||
self.assertEqual(row[8], 1000) # amount
|
||||
self.assertEqual(row[10], 45) # age = payment date - invoice date
|
||||
# Buckets: 30-60 filled, others empty.
|
||||
self.assertEqual(row["range1"], 0)
|
||||
self.assertEqual(row["range2"], 1000)
|
||||
self.assertEqual(row["range3"], 0)
|
||||
self.assertEqual(row["range4"], 0)
|
||||
|
||||
def test_payment_over_90_days_lands_in_90_above_bucket(self):
|
||||
# invoice 2026-01-01, paid 2026-06-01 -> 151 days after -> "90 Above" bucket.
|
||||
# Regression guard: with four range columns, a payment older than the last
|
||||
# threshold must fall into range4 rather than an unread range5 (showing 0).
|
||||
invoice = create_sales_invoice(customer="_Test Customer 2", rate=1000, posting_date="2026-01-01")
|
||||
payment = self.pay_invoice(invoice, "2026-06-01")
|
||||
|
||||
_columns, data = self.run_report()
|
||||
|
||||
row = self.find_payment_row(data, payment.name)
|
||||
self.assertIsNotNone(row, "Payment row not found in report output")
|
||||
|
||||
self.assertEqual(row["amount"], 1000)
|
||||
self.assertEqual(row["age"], 151)
|
||||
self.assertEqual(row["range1"], 0)
|
||||
self.assertEqual(row["range2"], 0)
|
||||
self.assertEqual(row["range3"], 0)
|
||||
self.assertEqual(row["range4"], 1000) # 90 Above captures the full amount
|
||||
self.assertEqual(row[11], 0) # range1 (0-30)
|
||||
self.assertEqual(row[12], 1000) # range2 (30-60)
|
||||
self.assertEqual(row[13], 0) # range3 (60-90)
|
||||
self.assertEqual(row[14], 0) # range4 (90 Above)
|
||||
|
||||
def test_columns_expose_expected_age_buckets(self):
|
||||
columns, _data = self.run_report()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user