Compare commits

..

2 Commits

Author SHA1 Message Date
Shllokkk
a6b56a4dd7 fix: resolving conflicts 2026-06-18 17:10:12 +05:30
S Sakthivel Murugan
c207b8425c fix(accounts): allow process statement of account generation with opening entries
(cherry picked from commit d0f1239d2b)

# Conflicts:
#	erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json
2026-06-18 11:18:12 +00:00
268 changed files with 1719 additions and 10351 deletions

View File

@@ -18,19 +18,7 @@ jobs:
cache: pip
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.1
semgrep:
name: semgrep
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: pip
uses: pre-commit/action@v3.0.0
- name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules

View File

@@ -50,6 +50,7 @@ repos:
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.118.3"
__version__ = "15.108.3"
def get_default_company(user=None):

View File

@@ -15,7 +15,7 @@ class ERPNextAddress(Address):
def link_address(self):
"""Link address based on owner"""
if self.get("is_your_company_address"):
if self.is_your_company_address:
return
return super().link_address()
@@ -26,9 +26,7 @@ class ERPNextAddress(Address):
self.is_your_company_address = 1
def validate_reference(self):
if self.get("is_your_company_address") and not [
row for row in self.links if row.link_doctype == "Company"
]:
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
frappe.throw(
_(
"Address needs to be linked to a Company. Please add a row for Company in the Links table."

View File

@@ -119,7 +119,6 @@ class Account(NestedSet):
self.validate_account_currency()
self.validate_root_company_and_sync_account_to_children()
self.validate_receivable_payable_account_type()
self.validate_stock_account_type_change()
def validate_parent_child_account_type(self):
if self.parent_account:
@@ -208,36 +207,6 @@ class Account(NestedSet):
frappe.msgprint(msg)
self.add_comment("Comment", msg)
def validate_stock_account_type_change(self):
doc_before_save = self.get_doc_before_save()
if not (doc_before_save and doc_before_save.account_type == "Stock"):
return
if self.account_type == "Stock":
return
if self.stock_ledger_entry_exists():
frappe.throw(
_(
"The account type of {0} cannot be changed from {1} because stock ledger entries exist against it."
).format(frappe.bold(self.name), frappe.bold(_("Stock")))
)
def stock_ledger_entry_exists(self):
from erpnext.stock import get_warehouse_account_map
warehouse_account = get_warehouse_account_map(self.company)
warehouses = [wh for wh, details in warehouse_account.items() if details.account == self.name]
if not warehouses:
return False
return bool(
frappe.db.count(
"Stock Ledger Entry",
filters={"warehouse": ("in", warehouses), "is_cancelled": 0},
)
)
def validate_root_details(self):
doc_before_save = self.get_doc_before_save()

View File

@@ -406,7 +406,8 @@
"Customer Deposits": {
"account_number": "2500",
"is_group": 0,
"root_type": "Liability"
"root_type": "Liability",
"account_type": "Payable"
}
},
"Non Current Liabilities": {

View File

@@ -313,31 +313,6 @@ class TestAccount(unittest.TestCase):
acc.account_currency = "USD"
self.assertRaises(frappe.ValidationError, acc.save)
def test_stock_account_type_change_with_ledger_entries(self):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
stock_account = get_warehouse_account(frappe.get_doc("Warehouse", warehouse))
make_stock_entry(
item_code="_Test Item",
target=warehouse,
company=company,
qty=5,
basic_rate=100,
)
account = frappe.get_doc("Account", stock_account)
self.assertEqual(account.account_type, "Stock")
account.account_type = ""
self.assertRaises(frappe.ValidationError, account.save)
account.reload()
account.account_name = f"{account.account_name} Updated"
account.save() # non-type change stays allowed
def test_account_balance(self):
from erpnext.accounts.utils import get_balance_on

View File

@@ -5,7 +5,6 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
class OverlapError(frappe.ValidationError):
@@ -35,20 +34,8 @@ class AccountingPeriod(Document):
# end: auto-generated types
def validate(self):
self.validate_dates()
self.validate_overlap()
def validate_dates(self):
if getdate(self.start_date) > getdate(self.end_date):
frappe.throw(_("Start Date cannot be after End Date"))
if getdate(self.end_date) > getdate(nowdate()):
frappe.throw(
_(
"Accounting Period cannot be created for a future date. End Date {0} is after today."
).format(frappe.bold(frappe.format(self.end_date, "Date")))
)
def before_insert(self):
self.bootstrap_doctypes_for_closing()

View File

@@ -4,7 +4,7 @@
import unittest
import frappe
from frappe.utils import nowdate
from frappe.utils import add_months, nowdate
from erpnext.accounts.doctype.accounting_period.accounting_period import (
ClosedAccountingPeriod,
@@ -47,7 +47,7 @@ def create_accounting_period(**args):
accounting_period = frappe.new_doc("Accounting Period")
accounting_period.start_date = args.start_date or nowdate()
accounting_period.end_date = args.end_date or nowdate()
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
accounting_period.company = args.company or "_Test Company"
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})

View File

@@ -79,7 +79,6 @@
"acc_frozen_upto",
"ignore_account_closing_balance",
"use_legacy_controller_for_pcv",
"pcv_job_timeout",
"column_break_25",
"frozen_accounts_modifier",
"tab_break_dpet",
@@ -652,14 +651,6 @@
"fieldtype": "Check",
"label": "Show Party Balance"
},
{
"default": "3600",
"depends_on": "eval: !doc.use_legacy_controller_for_pcv",
"description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher",
"fieldname": "pcv_job_timeout",
"fieldtype": "Int",
"label": "PCV Job Timeout (seconds)"
},
{
"default": "30, 60, 90, 120",
"fieldname": "default_ageing_range",
@@ -672,7 +663,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-24 12:59:41.868865",
"modified": "2026-05-18 12:16:33.679345",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -60,7 +60,6 @@ class AccountsSettings(Document):
merge_similar_account_heads: DF.Check
over_billing_allowance: DF.Currency
post_change_gl_entries: DF.Check
pcv_job_timeout: DF.Int
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
receivable_payable_remarks_length: DF.Int
reconciliation_queue_size: DF.Int

View File

@@ -115,7 +115,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"):
@@ -126,14 +126,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

View File

@@ -9,13 +9,6 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.set_query("reference_doctype", function () {
return {
filters: {
name: ["in", ["Sales Order", "Purchase Order"]],
},
};
});
frm.set_query("bank_account", function () {
return {
filters: {

View File

@@ -1,6 +1,5 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "ACC-BG-.YYYY.-.#####",
"creation": "2016-12-17 10:43:35.731631",
"doctype": "DocType",
@@ -51,7 +50,8 @@
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Document Type",
"options": "DocType"
"options": "DocType",
"read_only": 1
},
{
"fieldname": "reference_docname",
@@ -60,14 +60,14 @@
"options": "reference_doctype"
},
{
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
"depends_on": "eval: doc.bg_type == \"Receiving\"",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
"depends_on": "eval: doc.bg_type == \"Providing\"",
"fieldname": "supplier",
"fieldtype": "Link",
"label": "Supplier",
@@ -217,11 +217,11 @@
],
"is_submittable": 1,
"links": [],
"modified": "2026-05-25 18:12:10.768835",
"modified": "2025-09-26 00:38:17.584694",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Guarantee",
"naming_rule": "Expression",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -57,7 +57,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
filters.append(["date", "<=", to_date])
if from_date:
filters.append(["date", ">=", from_date])
transactions = frappe.get_list(
transactions = frappe.get_all(
"Bank Transaction",
fields=[
"date",
@@ -82,7 +82,6 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None):
@frappe.whitelist()
def get_account_balance(bank_account, till_date, company):
# returns account balance till the specified date
frappe.has_permission("Bank Account", "read", bank_account, throw=True)
account = frappe.db.get_value("Bank Account", bank_account, "account")
filters = frappe._dict(
{

View File

@@ -115,36 +115,6 @@ class TestBankTransaction(FrappeTestCase):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Amending a reconciled payment entry must not carry over its clearance date
def test_clearance_date_cleared_on_amend(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
payment.reload()
payment.cancel()
amended = frappe.copy_doc(payment)
amended.amended_from = payment.name
amended.docstatus = 0
amended.insert()
self.assertFalse(amended.clearance_date)
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(

View File

@@ -103,8 +103,8 @@ class Budget(Document):
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
"Budget cannot be assigned against {0}, as it's not an Income or Expense account"
).format(d.account)
)
if d.account in account_list:
@@ -425,11 +425,11 @@ def get_ordered_amount(args):
def get_other_condition(args, for_doc):
condition = f"expense_account = {frappe.db.escape(args.expense_account)}"
condition = "expense_account = '%s'" % (args.expense_account)
budget_against_field = args.get("budget_against_field")
if budget_against_field and args.get(budget_against_field):
condition += f" and child.{budget_against_field} = {frappe.db.escape(args.get(budget_against_field))}"
condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'"
if args.get("fiscal_year"):
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
@@ -437,7 +437,8 @@ def get_other_condition(args, for_doc):
"Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"]
)
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
condition += f""" and parent.{date_field}
between '{start_date}' and '{end_date}' """
return condition

View File

@@ -215,7 +215,6 @@ def build_forest(data):
for row in data:
account_name, parent_account, account_number, parent_account_number = row[0:4]
if account_number:
account_number = cstr(account_number).strip()
account_name = f"{account_number} - {account_name}"
if parent_account_number:
parent_account_number = cstr(parent_account_number).strip()

View File

@@ -169,10 +169,23 @@ frappe.ui.form.on("Dunning", {
},
get_dunning_letter_text: function (frm) {
if (frm.doc.dunning_type) {
frm.call("get_dunning_letter_text").then((r) => {
if (!r.exc) {
frm.refresh_fields();
}
frappe.call({
method: "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text",
args: {
dunning_type: frm.doc.dunning_type,
language: frm.doc.language,
doc: frm.doc,
},
callback: function (r) {
if (r.message) {
frm.set_value("body_text", r.message.body_text);
frm.set_value("closing_text", r.message.closing_text);
frm.set_value("language", r.message.language);
} else {
frm.set_value("body_text", "");
frm.set_value("closing_text", "");
}
},
});
}
},

View File

@@ -156,46 +156,6 @@ class Dunning(AccountsController):
"Serial and Batch Bundle",
]
@frappe.whitelist()
def get_dunning_letter_text(self):
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
if not self.dunning_type:
return
filters = {"parent": self.dunning_type, "is_default_language": 1}
if self.language:
filters.pop("is_default_language")
filters["language"] = self.language
letter_text = frappe.db.get_value(DOCTYPE, filters, FIELDS, as_dict=True)
if not letter_text:
msg = (
_("Dunning Letter for Dunning Type {0} in language '{1}' not found.").format(
frappe.bold(self.dunning_type), frappe.bold(self.language)
)
if self.language
else _("Dunning Letter for Dunning Type {0} not found.").format(
frappe.bold(self.dunning_type)
)
)
frappe.msgprint(msg, alert=True, indicator="yellow")
self.body_text = (
frappe.render_template(letter_text.body_text, self.as_dict(), restrict_globals=True)
if letter_text
else None
)
self.closing_text = (
frappe.render_template(letter_text.closing_text, self.as_dict(), restrict_globals=True)
if letter_text
else None
)
self.language = letter_text.language if letter_text else self.language
def update_linked_dunnings(doc, previous_outstanding_amount):
if (
@@ -274,3 +234,35 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
& (overdue_payment.sales_invoice == sales_invoice)
)
).run(as_dict=True)
@frappe.whitelist()
def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict:
DOCTYPE = "Dunning Letter Text"
FIELDS = ["body_text", "closing_text", "language"]
if isinstance(doc, str):
doc = json.loads(doc)
if not language:
language = doc.get("language")
letter_text = None
if language:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
)
if not letter_text:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1
)
if not letter_text:
return {}
return {
"body_text": frappe.render_template(letter_text.body_text, doc),
"closing_text": frappe.render_template(letter_text.closing_text, doc),
"language": letter_text.language,
}

View File

@@ -3,10 +3,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import comma_and
from frappe.utils.jinja import validate_template
class DunningType(Document):
@@ -33,134 +30,3 @@ class DunningType(Document):
def autoname(self):
company_abbr = frappe.get_value("Company", self.company, "abbr")
self.name = f"{self.dunning_type} - {company_abbr}"
def validate(self):
self.validate_dunning_letter_text()
self.validate_income_account()
self.validate_cost_center()
self.set_default_dunning_type()
def validate_dunning_letter_text(self):
self.validate_languages()
self.validate_is_default_language()
self.validate_dunning_letter_text_templates()
def validate_income_account(self):
if not self.income_account:
return
account = frappe.get_cached_doc("Account", self.income_account)
msg = []
if account.company != self.company:
msg.append(
_(
"{0} doesn't belong to Company {1}. Please select an Income Account that belongs to Company {1}."
).format(frappe.bold(self.income_account), frappe.bold(self.company))
)
if account.disabled:
msg.append(
_("{0} is disabled. Please select a valid Income Account.").format(
frappe.bold(self.income_account)
)
)
if account.root_type != "Income":
msg.append(
_("{0} is not an Income Account. Please select a valid Income Account.").format(
frappe.bold(self.income_account)
)
)
if account.is_group:
msg.append(
_("{0} is a group account. Please select a non-group Income Account.").format(
frappe.bold(self.income_account)
)
)
if msg:
frappe.msgprint(
msg,
title=_("Income Account Validation Error"),
as_list=True,
raise_exception=frappe.ValidationError,
)
def validate_cost_center(self):
if not self.cost_center:
return
cost_center = frappe.get_cached_doc("Cost Center", self.cost_center)
msg = []
if cost_center.company != self.company:
msg.append(
_(
"{0} doesn't belong to Company {1}. Please select a Cost Center that belongs to Company {1}."
).format(frappe.bold(self.cost_center), frappe.bold(self.company))
)
if cost_center.disabled:
msg.append(
_("{0} is disabled. Please select an enabled Cost Center.").format(
frappe.bold(self.cost_center)
)
)
if cost_center.is_group:
msg.append(
_("{0} is a group Cost Center. Please select a non-group Cost Center.").format(
frappe.bold(self.cost_center)
)
)
if msg:
frappe.msgprint(
msg,
title=_("Cost Center Validation Error"),
as_list=True,
raise_exception=frappe.ValidationError,
)
def validate_languages(self):
languages = [d.language for d in self.dunning_letter_text]
if len(languages) == len(set(languages)):
return
frappe.throw(_("Duplicate languages found on Dunning Letter Text. Keep only one of them."))
def validate_is_default_language(self):
is_default_language_list = [
d.language for d in self.dunning_letter_text if d.is_default_language == 1
]
if len(is_default_language_list) <= 1:
return
frappe.throw(
_("{0} languages are marked as default languages. Please select only one of them.").format(
comma_and(is_default_language_list, add_quotes=True)
)
)
def validate_dunning_letter_text_templates(self):
for d in self.dunning_letter_text:
if d.body_text:
validate_template(d.body_text, restrict_globals=True)
if d.closing_text:
validate_template(d.closing_text, restrict_globals=True)
def set_default_dunning_type(self):
if self.is_default != 1:
return
frappe.db.set_value(
"Dunning Type",
{"company": self.company, "is_default": 1, "name": ["!=", self.name]},
"is_default",
0,
)

View File

@@ -1,199 +1,9 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
# import frappe
import unittest
def make_dunning_type(dunning_type, company="_Test Company", **kwargs):
doc = frappe.new_doc("Dunning Type")
doc.dunning_type = dunning_type
doc.company = company
doc.dunning_fee = kwargs.get("dunning_fee", 100)
doc.rate_of_interest = kwargs.get("rate_of_interest", 5)
doc.is_default = kwargs.get("is_default", 0)
if "income_account" in kwargs:
doc.income_account = kwargs["income_account"]
elif kwargs.get("income_account") is not False:
doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1"
if "cost_center" in kwargs:
doc.cost_center = kwargs["cost_center"]
elif kwargs.get("cost_center") is not False:
doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1"
for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]):
doc.append("dunning_letter_text", row)
return doc
class TestDunningType(FrappeTestCase):
def test_income_account_must_belong_to_company(self):
doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1")
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
def test_income_account_must_not_be_disabled(self):
disabled_account = frappe.get_doc(
{
"doctype": "Account",
"account_name": "_Test Disabled Income Account",
"parent_account": "Direct Income - _TC",
"company": "_Test Company",
"account_type": "Income Account",
"disabled": 1,
}
).insert()
doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name)
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
def test_income_account_must_be_income_type(self):
doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert)
def test_income_account_must_not_be_group(self):
doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert)
def test_income_account_is_optional(self):
doc = make_dunning_type("_Test Dunning No Income Account", income_account=False)
doc.insert()
self.assertFalse(doc.income_account)
def test_valid_income_account_passes(self):
doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC")
doc.insert()
self.assertEqual(doc.income_account, "Sales - _TC")
def test_cost_center_must_belong_to_company(self):
doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1")
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
def test_cost_center_must_not_be_disabled(self):
disabled_cc = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Disabled Cost Center",
"parent_cost_center": "_Test Company - _TC",
"company": "_Test Company",
"disabled": 1,
}
).insert()
doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name)
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
def test_cost_center_must_not_be_group(self):
doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC")
self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert)
def test_cost_center_is_optional(self):
doc = make_dunning_type("_Test Dunning No CC", cost_center=False)
doc.insert()
self.assertFalse(doc.cost_center)
def test_valid_cost_center_passes(self):
doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC")
doc.insert()
self.assertEqual(doc.cost_center, "Main - _TC")
def test_duplicate_languages_not_allowed(self):
doc = make_dunning_type(
"_Test Dunning Duplicate Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one"},
{"language": "en", "body_text": "Body two"},
],
)
self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert)
def test_unique_languages_allowed(self):
doc = make_dunning_type(
"_Test Dunning Unique Languages",
dunning_letter_text=[
{"language": "en", "body_text": "Body one"},
{"language": "de", "body_text": "Body two"},
],
)
doc.insert()
self.assertEqual(len(doc.dunning_letter_text), 2)
def test_only_one_default_language_allowed(self):
doc = make_dunning_type(
"_Test Dunning Multiple Default Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one", "is_default_language": 1},
{"language": "de", "body_text": "Body two", "is_default_language": 1},
],
)
self.assertRaisesRegex(
frappe.ValidationError, "languages are marked as default languages", doc.insert
)
def test_single_default_language_allowed(self):
doc = make_dunning_type(
"_Test Dunning Single Default Language",
dunning_letter_text=[
{"language": "en", "body_text": "Body one", "is_default_language": 1},
{"language": "de", "body_text": "Body two", "is_default_language": 0},
],
)
doc.insert()
self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1)
def test_invalid_jinja_template_in_body_text_raises(self):
doc = make_dunning_type(
"_Test Dunning Invalid Body Template",
dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}],
)
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
def test_invalid_jinja_template_in_closing_text_raises(self):
doc = make_dunning_type(
"_Test Dunning Invalid Closing Template",
dunning_letter_text=[
{"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"}
],
)
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
def test_valid_jinja_template_passes(self):
doc = make_dunning_type(
"_Test Dunning Valid Template",
dunning_letter_text=[
{
"language": "en",
"body_text": "Outstanding amount is {{ outstanding_amount }}",
"closing_text": "Regards, {{ company }}",
}
],
)
doc.insert()
self.assertTrue(doc.name)
def test_set_default_dunning_type_unsets_previous_default(self):
first = make_dunning_type("_Test Dunning Default One", is_default=1)
first.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1)
second = make_dunning_type("_Test Dunning Default Two", is_default=1)
second.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0)
self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1)
def test_set_default_dunning_type_scoped_per_company(self):
company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1)
company_1.insert()
company_2 = make_dunning_type(
"_Test Dunning Default Co2",
company="_Test Company 1",
is_default=1,
)
company_2.insert()
self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1)
self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1)
class TestDunningType(unittest.TestCase):
pass

View File

@@ -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", {

View File

@@ -8,7 +8,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 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
@@ -90,31 +90,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 = (
@@ -129,31 +123,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()
@@ -367,7 +342,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(
@@ -597,49 +571,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.journal_entry import make_reverse_journal_entry
if drafts := frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": ["in", journals]},
pluck="name",
):
part = "journals are" if len(drafts) > 1 else "journal is"
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
frappe.throw(
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
)
else:
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.save()
frappe.msgprint(
_("A draft reverse 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):
"""
@@ -688,10 +619,6 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
def get_account_details(
company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None
):
if not account:
return
frappe.has_permission("Account", doc=account, throw=True)
if not (company and posting_date):
frappe.throw(_("Company and Posting Date is mandatory"))

View File

@@ -130,8 +130,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
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()
@@ -214,8 +213,7 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
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()
@@ -289,91 +287,3 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
@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()
# submit
draft = frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
pluck="name",
)
self.assertIsNotNone(draft)
frappe.get_doc("Journal Entry", draft[0]).submit()
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)

View File

@@ -40,10 +40,6 @@ frappe.ui.form.on("Journal Entry", {
},
refresh: function (frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
erpnext.journal_entry.lock_reversal_entry(frm);
}
erpnext.toggle_naming_series();
if (frm.doc.docstatus > 0) {
@@ -513,14 +509,6 @@ $.extend(erpnext.journal_entry, {
});
},
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},
set_debit_credit_in_company_currency: function (frm, cdt, cdn) {
var row = locals[cdt][cdn];

View File

@@ -21,7 +21,6 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details,
)
from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -124,9 +123,6 @@ class JournalEntry(AccountsController):
if not self.is_opening:
self.is_opening = "No"
if self.is_opening == "Yes":
validate_opening_entry_against_pcv(self.company)
self.clearance_date = None
self.validate_party()
@@ -1188,11 +1184,7 @@ class JournalEntry(AccountsController):
self.validate_total_debit_and_credit()
def get_values(self):
cond = (
f" and outstanding_amount <= {flt(self.write_off_amount)}"
if flt(self.write_off_amount) > 0
else ""
)
cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else ""
if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql(

View File

@@ -1,15 +1,12 @@
frappe.listview_settings["Journal Entry"] = {
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark", "reversal_of"],
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"],
get_indicator: function (doc) {
if (doc.docstatus == 0) {
return [__("Draft", "red", "docstatus,=,0")];
} else if (doc.docstatus == 2) {
return [__("Cancelled", "grey", "docstatus,=,2")];
} else 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}`];
} else {
return [__(doc.voucher_type), "blue", "voucher_type,=," + doc.voucher_type];
}
},
};

View File

@@ -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,
@@ -82,31 +75,29 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
setup_company_filters: function (frm) {
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
frm.events.apply_company_query_filter(frm, "project", "invoices");
frm.events.apply_company_query_filter(frm, "project");
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
account_type: "Temporary",
is_group: 0,
});
},
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
const query = function (doc) {
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
...filters,
},
};
};
});
if (child_doctype) {
frm.set_query(field_name, child_doctype, query);
} else {
frm.set_query(field_name, query);
}
frm.set_query("cost_center", function (doc) {
return {
filters: {
company: doc.company,
},
};
});
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
return {
filters: {
company: doc.company,
},
};
});
},
company: function (frm) {
@@ -130,7 +121,10 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
},
invoice_type: function (frm) {
frm.clear_table("invoices");
$.each(frm.doc.invoices, (idx, row) => {
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
row.party = "";
});
frm.refresh_fields();
},
@@ -171,19 +165,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
});
frappe.ui.form.on("Opening Invoice Creation Tool Item", {
invoices_add: (frm, cdt, cdn) => {
const row = frappe.get_doc(cdt, cdn);
const field_copy = [];
["project", "cost_center"].forEach((fieldname) => {
if (frm.doc[fieldname]) {
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
} else {
field_copy.push(fieldname);
}
});
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
invoices_add: (frm) => {
frm.trigger("update_invoice_table");
},
});

View File

@@ -122,17 +122,6 @@ class OpeningInvoiceCreationTool(Document):
if not row.get(scrub(d)):
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
self.validate_temporary_opening_account(row)
def validate_temporary_opening_account(self, row):
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
if account_type != "Temporary":
frappe.throw(
_("Row #{0}: {1} account is not of type {2}").format(
row.idx, row.temporary_opening_account, "Temporary"
)
)
def get_invoices(self):
invoices = []
for row in self.invoices:
@@ -202,7 +191,6 @@ class OpeningInvoiceCreationTool(Document):
"description": row.item_name or "Opening Invoice Item",
income_expense_account_field: row.temporary_opening_account,
"cost_center": cost_center,
"project": row.get("project") or self.get("project"),
}
)
@@ -266,32 +254,22 @@ 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
# imported earlier in this batch and the error logs of earlier failures (the
# latter only survive on mariadb because the Error Log table is MyISAM; on
# 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)
doc.submit()
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()
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 {} errors while creating opening invoices. Check {} for more details").format(
@@ -303,7 +281,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(
@@ -311,7 +289,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,
)

View File

@@ -3,7 +3,6 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, today
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
@@ -12,7 +11,6 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.projects.doctype.project.test_project import make_project
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
@@ -29,26 +27,21 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
self,
invoice_type="Sales",
company=None,
invoices=None,
project=None,
cost_center=None,
party_1=None,
party_2=None,
invoice_number=None,
department=None,
return_doc=False,
):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(
invoice_type=invoice_type,
company=company,
invoices=invoices,
project=project,
cost_center=cost_center,
party_1=party_1,
party_2=party_2,
invoice_number=invoice_number,
department=department,
)
doc.update(args)
if return_doc:
return doc
return doc.make_invoices()
def test_opening_sales_invoice_creation(self):
@@ -57,8 +50,8 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["customer", "outstanding_amount", "status"],
0: ["_Test Customer", 200, "Overdue"],
1: ["_Test Customer 1", 200, "Overdue"],
0: ["_Test Customer", 300, "Overdue"],
1: ["_Test Customer 1", 250, "Overdue"],
}
self.check_expected_values(invoices, expected_value)
@@ -75,34 +68,48 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
for field_idx, field in enumerate(expected_value["keys"]):
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
def test_opening_invoice_requires_temporary_account_type(self):
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
self.assertRaises(frappe.ValidationError, doc.make_invoices)
def test_opening_purchase_invoice_creation(self):
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
self.assertEqual(len(invoices), 2)
expected_value = {
"keys": ["supplier", "outstanding_amount", "status"],
0: ["_Test Supplier", 200, "Overdue"],
1: ["_Test Supplier 1", 200, "Overdue"],
0: ["_Test Supplier", 300, "Overdue"],
1: ["_Test Supplier 1", 250, "Overdue"],
}
self.check_expected_values(invoices, expected_value, "Purchase")
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
old_default_receivable_account = frappe.db.get_value(
"Company", "_Test Opening Invoice Company", "default_receivable_account"
)
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
frappe.db.set_value("Company", company, "default_receivable_account", "")
self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[{"party": party_1}, {"party": party_2}],
)
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
cc = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "_Test Opening Invoice Company",
"is_group": 1,
"company": "_Test Opening Invoice Company",
}
)
cc.insert(ignore_mandatory=True)
cc2 = frappe.get_doc(
{
"doctype": "Cost Center",
"cost_center_name": "Main",
"is_group": 0,
"company": "_Test Opening Invoice Company",
"parent_cost_center": cc.name,
}
)
cc2.insert()
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
# Check if missing debit account error raised
error_log = frappe.db.exists(
@@ -112,113 +119,74 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
self.assertTrue(error_log)
# teardown
frappe.db.set_value(
"Company",
"_Test Opening Invoice Company",
"default_receivable_account",
old_default_receivable_account,
)
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
def test_renaming_of_invoice_using_invoice_number_field(self):
company = "_Test Opening Invoice Company"
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
inv_num = f"TEST-NEW-INV-{frappe.generate_hash(length=8)}"
invoices = self.make_invoices(
company="_Test Opening Invoice Company",
invoices=[
{"party": party_1, "invoice_number": inv_num},
{"party": party_2},
],
self.make_invoices(
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
)
self.assertEqual(invoices[0], inv_num)
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
# teardown
for inv in [sales_inv1, sales_inv2]:
doc = frappe.get_doc("Sales Invoice", inv)
doc.cancel()
def test_opening_invoice_with_accounting_dimension(self):
invoices = self.make_invoices(
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
)
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
expected_value = {
"keys": ["customer", "outstanding_amount", "status", "department"],
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
}
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
)
project_1 = make_project(
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
)
project_2 = make_project(
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
)
doc.invoices[0].project = project_1.name
doc.invoices[1].project = project_2.name
invoices = doc.make_invoices()
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
@classmethod
def tearDownClass(cls):
def tearDown(self):
disable_dimension()
super().tearDownClass()
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")
default_invoices = []
default_invoice_rows = [
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party}",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
{
"qty": 1.0,
"outstanding_amount": 200,
"party": f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": add_days(today(), -10),
"posting_date": add_days(today(), -15),
"temporary_opening_account": get_temporary_opening_account(company),
},
]
for row in args.get("invoices") or default_invoice_rows:
default_invoices.append(
{
"qty": row.get("qty") or 1.0,
"outstanding_amount": row.get("outstanding_amount") or 200,
"party": row.get("party") or f"_Test {party}",
"item_name": row.get("item_name") or "Opening Item",
"due_date": row.get("due_date") or add_days(today(), -10),
"posting_date": row.get("posting_date") or add_days(today(), -15),
"temporary_opening_account": row.get("temporary_opening_account")
or get_temporary_opening_account(company),
"invoice_number": row.get("invoice_number"),
"project": row.get("project"),
"cost_center": row.get("cost_center"),
}
)
invoice_dict = frappe._dict(
{
"company": company,
"invoice_type": args.get("invoice_type", "Sales"),
"project": args.get("project"),
"cost_center": args.get("cost_center"),
"invoices": default_invoices,
"invoices": [
{
"qty": 1.0,
"outstanding_amount": 300,
"party": args.get("party_1") or f"_Test {party}",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": args.get("invoice_number"),
},
{
"qty": 2.0,
"outstanding_amount": 250,
"party": args.get("party_2") or f"_Test {party} 1",
"item_name": "Opening Item",
"due_date": "2016-09-10",
"posting_date": "2016-09-05",
"temporary_opening_account": get_temporary_opening_account(company),
"invoice_number": None,
},
],
}
)
invoice_dict.update(args)
invoice_dict.invoices = default_invoices
return invoice_dict

View File

@@ -19,8 +19,7 @@
"qty",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project"
"dimension_col_break"
],
"fields": [
{
@@ -80,7 +79,6 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Outstanding Amount",
"options": "Company:company:default_currency",
"reqd": 1
},
{
@@ -113,17 +111,11 @@
"fieldname": "invoice_number",
"fieldtype": "Data",
"label": "Invoice Number"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
}
],
"istable": 1,
"links": [],
"modified": "2026-07-03 15:17:11.938499",
"modified": "2022-03-21 19:31:45.382656",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool Item",
@@ -134,4 +126,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -25,7 +25,6 @@ class OpeningInvoiceCreationToolItem(Document):
party: DF.DynamicLink
party_type: DF.Link | None
posting_date: DF.Date | None
project: DF.Link | None
qty: DF.Data | None
temporary_opening_account: DF.Link | None
# end: auto-generated types

View File

@@ -769,21 +769,17 @@ frappe.ui.form.on("Payment Entry", {
frm.set_paid_amount_based_on_received_amount = true;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
// target exchange rate should always be same as source if both account currencies is same
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
frm.set_value("received_amount", frm.doc.paid_amount);
} else {
const target_rate =
flt(frm.doc.target_exchange_rate) ||
(company_currency == frm.doc.paid_to_account_currency ? 1 : 0);
if (target_rate) {
frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate);
}
frm.set_value(
"paid_amount",
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
);
}
// set_unallocated_amount is called by below method,
@@ -799,23 +795,18 @@ frappe.ui.form.on("Payment Entry", {
target_exchange_rate: function (frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.received_amount && frm.doc.target_exchange_rate) {
frm.set_value(
"base_received_amount",
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (
!frm.doc.source_exchange_rate &&
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
frm.set_value("paid_amount", frm.doc.received_amount);
} else {
const source_rate =
flt(frm.doc.source_exchange_rate) ||
(company_currency == frm.doc.paid_from_account_currency ? 1 : 0);
if (source_rate) {
frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate);
}
frm.set_value(
"received_amount",
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
);
}
// set_unallocated_amount is called by below method,

View File

@@ -1197,9 +1197,9 @@ class PaymentEntry(AccountsController):
continue
if tax.add_deduct_tax == "Add":
included_taxes += flt(tax.base_tax_amount)
included_taxes += tax.base_tax_amount
else:
included_taxes -= flt(tax.base_tax_amount)
included_taxes -= tax.base_tax_amount
return included_taxes
@@ -2691,9 +2691,6 @@ def get_party_details(company, party_type, party, date, cost_center=None):
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)
account_balance = (
@@ -2710,7 +2707,7 @@ def get_party_details(company, party_type, party, date, cost_center=None):
)
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,
@@ -2811,7 +2808,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(

View File

@@ -1118,27 +1118,6 @@ class TestPaymentEntry(FrappeTestCase):
self.assertEqual(gl_entries, expected_gl_entries)
def test_payment_entry_with_inclusive_tax(self):
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
payment_entry = create_payment_entry(paid_amount=1180)
payment_entry.append(
"taxes",
{
"account_head": "_Test Account Service Tax - _TC",
"charge_type": "On Paid Amount",
"rate": 18,
"included_in_paid_amount": 1,
"add_deduct_tax": "Add",
"description": "Service Tax",
},
)
payment_entry.save()
payment_entry.submit()
# 1180 incl 18% => 1000 base + 180 tax
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
def test_payment_entry_against_onhold_purchase_invoice(self):
pi = make_purchase_invoice()

View File

@@ -796,17 +796,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)

View File

@@ -155,7 +155,6 @@ class TestPaymentReconciliation(FrappeTestCase):
sinv = create_sales_invoice(
qty=qty,
rate=rate,
posting_date=posting_date,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -2147,7 +2146,7 @@ class TestPaymentReconciliation(FrappeTestCase):
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)
@@ -2541,76 +2540,6 @@ class TestPaymentReconciliation(FrappeTestCase):
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 make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):

View File

@@ -367,7 +367,6 @@ class PaymentRequest(Document):
bank_amount=bank_amount,
created_from_payment_request=True,
)
payment_entry.set_missing_ref_details(force=True)
payment_entry.update(
{
@@ -835,7 +834,6 @@ def resend_payment_email(docname):
@frappe.whitelist()
def make_payment_entry(docname):
doc = frappe.get_doc("Payment Request", docname)
doc.check_permission("read")
return doc.create_payment_entry(submit=False).as_dict()

View File

@@ -618,22 +618,6 @@ class TestPaymentRequest(FrappeTestCase):
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

View File

@@ -379,15 +379,12 @@ class TestPeriodClosingVoucher(unittest.TestCase):
self.make_period_closing_voucher(posting_date="2021-03-31")
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
totals_after_cancel = frappe.db.sql(
"""

View File

@@ -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"))

View File

@@ -336,31 +336,6 @@ class TestPricingRule(FrappeTestCase):
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",

View File

@@ -86,55 +86,48 @@ 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.flags.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.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
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="3600",
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()
@@ -251,12 +244,10 @@ 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")
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)
@@ -266,23 +257,22 @@ 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.flags.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",
timeout=timeout,
timeout="3600",
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,
@@ -309,7 +299,7 @@ def schedule_next_date(docname: str):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
timeout=timeout,
timeout="3600",
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
@@ -447,11 +437,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.flags.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")
@@ -537,10 +522,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":
@@ -587,20 +572,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.flags.in_test:
frappe.db.commit() # nosemgrep
# chain call
schedule_next_date(docname)

View File

@@ -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"],
)

View File

@@ -13,7 +13,7 @@
</div>
{% endif %}
</div>
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
<div>
{% if filters.party[0] == filters.party_name[0] %}
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>

View File

@@ -16,6 +16,7 @@
"categorize_by",
"cost_center",
"territory",
"show_opening_entries",
"ignore_exchange_rate_revaluation_journals",
"ignore_cr_dr_notes",
"column_break_14",
@@ -399,10 +400,17 @@
"fieldtype": "Select",
"label": "Categorize By",
"options": "\nCategorize by Voucher\nCategorize by Voucher (Consolidated)"
},
{
"default": "0",
"depends_on": "eval:(doc.report == 'General Ledger');",
"fieldname": "show_opening_entries",
"fieldtype": "Check",
"label": "Show Opening Entries"
}
],
"links": [],
"modified": "2025-10-07 12:19:20.719898",
"modified": "2026-06-01 15:37:07.660442",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -73,6 +73,7 @@ class ProcessStatementOfAccounts(Document):
sales_person: DF.Link | None
sender: DF.Link | None
show_net_values_in_party_account: DF.Check
show_opening_entries: DF.Check
show_remarks: DF.Check
start_date: DF.Date | None
subject: DF.Data | None
@@ -97,9 +98,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."))
@@ -249,7 +250,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
"categorize_by": doc.categorize_by,
"currency": doc.currency,
"project": [p.project_name for p in doc.project],
"show_opening_entries": 0,
"show_opening_entries": doc.show_opening_entries,
"include_default_book_entries": 0,
"tax_id": tax_id if tax_id else None,
"show_net_values_in_party_account": doc.show_net_values_in_party_account,
@@ -390,6 +391,7 @@ def get_context(customer, doc):
return {
"doc": template_doc,
"customer": frappe.get_doc("Customer", customer),
"frappe": frappe.utils,
}
@@ -439,8 +441,6 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
when Is Billing Contact checked
and Primary email- email with Is Primary checked"""
frappe.has_permission("Customer", "read", customer_name, throw=True)
billing_email = frappe.db.sql(
"""
SELECT
@@ -484,7 +484,6 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr
@frappe.whitelist()
def download_statements(document_name):
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
doc.check_permission("read")
report = get_report_pdf(doc)
if report:
frappe.local.response.filename = doc.name + ".pdf"
@@ -501,15 +500,15 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
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")

View File

@@ -1397,10 +1397,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",
@@ -1662,7 +1660,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-07-12 23:54:21.263951",
"modified": "2026-03-17 20:44:00.221219",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -2924,24 +2924,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
# Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail
self.assertRaises(frappe.ValidationError, pi.submit)
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_po_is_blocked(self):
service_item = create_item(
"_Test Service Item Non Stock PI",
is_stock_item=0,
is_purchase_item=1,
).name
po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False)
po.submit()
pi = make_pi_from_po(po.name)
pi.items[0].qty = 10 # overbill by 100 %
pi.save()
with self.assertRaises(frappe.ValidationError):
pi.submit()
def test_discount_percentage_not_set_when_amount_is_manually_set(self):
pi = make_purchase_invoice(do_not_save=True)
discount_amount = 7

View File

@@ -22,50 +22,27 @@ frappe.ui.form.on("Repost Accounting Ledger", {
},
refresh: function (frm) {
// the server refuses only while the job is alive, so a dead one can be restarted here
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
frm.add_custom_button(__("Start Reposting"), () => {
frm.events.start_repost(frm);
frm.add_custom_button(__("Show Preview"), () => {
frm.call({
method: "generate_preview",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
});
}
if (frm.doc.docstatus != 2) {
frm.add_custom_button(__("Show Preview"), () => {
frm.events.generate_preview(frm);
});
}
},
generate_preview: function (frm) {
frm.call({
method: "generate_preview",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
});
},
start_repost: function (frm) {
frm.call({
method: "start_repost",
doc: frm.doc,
callback: function (r) {
frm.reload_doc();
},
});
},
});

View File

@@ -1,6 +1,5 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2023-07-04 13:07:32.923675",
"default_view": "List",
"doctype": "DocType",
@@ -8,24 +7,16 @@
"engine": "InnoDB",
"field_order": [
"company",
"delete_cancelled_entries",
"column_break_vpup",
"status",
"delete_cancelled_entries",
"section_break_metl",
"vouchers",
"error_section",
"error_log",
"miscellaneous_section",
"amended_from",
"column_break_hrah",
"scheduled_job"
"amended_from"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"options": "Company"
},
@@ -57,54 +48,12 @@
"fieldname": "delete_cancelled_entries",
"fieldtype": "Check",
"label": "Delete Cancelled Ledger Entries"
},
{
"fieldname": "error_section",
"fieldtype": "Section Break",
"label": "Error"
},
{
"fieldname": "error_log",
"fieldtype": "Code",
"label": "Error Log",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "miscellaneous_section",
"fieldtype": "Section Break",
"label": "Miscellaneous"
},
{
"fieldname": "column_break_hrah",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.docstatus >= 1;",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
"read_only": 1
},
{
"fieldname": "scheduled_job",
"fieldtype": "Link",
"hidden": 1,
"label": "Scheduled Job",
"no_copy": 1,
"options": "RQ Job",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-07-28 00:56:50.290314",
"modified": "2024-06-03 17:30:37.012593",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger",
@@ -131,4 +80,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -7,14 +7,7 @@ import frappe
from frappe import _, qb
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
from frappe.model.document import Document
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
from frappe.utils.data import comma_and
from frappe.utils.scheduler import is_scheduler_inactive
# a batch has to finish well within the timeout of the job reposting it
MAX_VOUCHERS_PER_REPOST = 50
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
from erpnext.stock import get_warehouse_account_map
@@ -35,11 +28,6 @@ class RepostAccountingLedger(Document):
amended_from: DF.Link | None
company: DF.Link | None
delete_cancelled_entries: DF.Check
error_log: DF.Code | None
scheduled_job: DF.Link | None
status: DF.Literal[
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
]
vouchers: DF.Table[RepostAccountingLedgerItems]
# end: auto-generated types
@@ -49,11 +37,6 @@ class RepostAccountingLedger(Document):
def validate(self):
self.validate_vouchers()
self.validate_repost_preconditions()
def validate_repost_preconditions(self):
"""The checks a repost queued days ago could have outlived, re-run before it touches
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
self.validate_for_closed_fiscal_year()
self.validate_for_deferred_accounting()
@@ -90,52 +73,8 @@ class RepostAccountingLedger(Document):
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
def validate_vouchers(self):
if not self.vouchers:
frappe.throw(_("Add atleast one voucher to repost."))
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
frappe.throw(
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
MAX_VOUCHERS_PER_REPOST
)
)
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
self.validate_no_duplicate_vouchers()
self.validate_vouchers_are_submitted()
def validate_no_duplicate_vouchers(self):
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
if len(vouchers) != len(set(vouchers)):
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
def validate_vouchers_are_submitted(self):
voucher_type_wise_map = {}
for d in self.vouchers:
voucher_type_wise_map.setdefault(d.voucher_type, [])
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
non_submitted_vouchers = []
for key in voucher_type_wise_map.keys():
non_submitted_vouchers.extend(
frappe.get_all(
key,
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
pluck="name",
)
)
if non_submitted_vouchers:
frappe.throw(
_("The following vouchers are not submitted: {0}").format(
comma_and(non_submitted_vouchers, add_quotes=True)
)
)
def on_discard(self):
self.db_set("status", "Cancelled")
if self.vouchers:
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
def get_existing_ledger_entries(self):
vouchers = [x.voucher_no for x in self.vouchers]
@@ -200,245 +139,80 @@ class RepostAccountingLedger(Document):
return rendered_page
def on_submit(self):
self.start_repost()
def before_cancel(self):
self._raise_error_if_reposting_in_progress()
def on_cancel(self):
self.db_set("status", "Cancelled")
def _raise_error_if_reposting_in_progress(self):
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
frappe.throw(_("Reposting is still in progress in background."))
@frappe.whitelist()
def start_repost(self):
if self.docstatus != 1:
frappe.throw(_("Reposting can be started only for submitted document."))
# under a row lock, so two concurrent starts cannot both get past here
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
if status in ("Completed", "Cancelled"):
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
# died leaves the status behind and the document has to stay restartable
self._raise_error_if_reposting_in_progress()
self.check_permission("write")
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
if is_scheduler_inactive():
frappe.msgprint(
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
alert=True,
indicator="orange",
if len(self.vouchers) > 5:
job_name = "repost_accounting_ledger_" + self.name
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
account_repost_doc=self.name,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
)
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
_enqueue_repost(self.name)
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
frappe.msgprint(_("Repost has started in the background"))
else:
start_repost(self.name)
def _repost_job_id(repost_doc_name: str) -> str:
"""Derived from the document, so a repost can only ever have one job."""
return f"repost_accounting_ledger::{repost_doc_name}"
def _enqueue_repost(repost_doc_name: str) -> None:
"""Hand the repost to a background worker.
Tests run it in the foreground, inside their own transaction: documents edited after submit
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
ledger right after doing so.
"""
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
repost_doc_name=repost_doc_name,
commit=not frappe.flags.in_test,
queue="long",
timeout=1500,
job_id=_repost_job_id(repost_doc_name),
deduplicate=True,
enqueue_after_commit=True,
now=frappe.flags.in_test,
)
def _lock_vouchers(vouchers) -> dict:
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
Returns them keyed by voucher, so reposting does not load them again. These are file locks
under the site directory: they serialise nothing across hosts that do not share it, and a
worker killed outright leaves them behind until they expire.
"""
locked_docs = {}
try:
for x in vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
doc.lock()
locked_docs[(x.voucher_type, x.voucher_no)] = doc
except Exception:
for doc in locked_docs.values():
doc.unlock()
raise
return locked_docs
def repost(repost_doc_name: str, commit: bool = True):
"""Repost every voucher of the document, one transaction at a time.
`commit` says whether this call owns the transaction. The background job does, and commits
after every voucher so progress survives a crash; a caller inside its own passes `False`.
"""
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
frappe.flags.through_repost_accounting_ledger = True
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
locked_docs = {}
try:
repost_doc.validate_repost_preconditions()
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
# and not reposted again
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
locked_docs = _lock_vouchers(pending)
repost_doc.db_set("status", "In Progress", commit=commit)
for position, x in enumerate(pending, start=1):
frappe.publish_progress(
position * 100 / len(pending),
doctype=repost_doc.doctype,
docname=repost_doc.name,
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
)
save_point = "reposting"
frappe.db.savepoint(save_point=save_point)
try:
doc = locked_docs[(x.voucher_type, x.voucher_no)]
if doc.docstatus == 2:
x.db_set({"status": "Skipped", "traceback": ""})
continue
if repost_doc.delete_cancelled_entries:
_delete_accounting_ledger_entries(doc.doctype, doc.name)
_delete_adv_pl_entries(doc.doctype, doc.name)
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
except Exception:
frappe.db.rollback(save_point=save_point)
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
else:
x.db_set({"status": "Reposted", "traceback": ""})
finally:
if commit:
frappe.db.commit() # nosemgrep
except Exception:
if commit:
frappe.db.rollback()
_record_repost_failure(repost_doc, commit=commit)
raise
else:
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
finally:
for doc in locked_docs.values():
doc.unlock()
if commit:
frappe.db.commit() # nosemgrep
def _derive_status(repost_doc) -> str:
"""Vouchers are committed one by one, so the status follows what was actually handled."""
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
if handled == len(repost_doc.vouchers):
return "Completed"
elif handled == 0:
return "Failed"
return "Partially Reposted"
def _record_repost_failure(repost_doc, commit=False) -> None:
"""Persist the traceback of a run that could not finish, without discarding its progress."""
# the traceback with frame locals goes to the Error Log, which is permissioned separately
traceback = frappe.get_traceback()
frappe.log_error(
title=_("Unable to Repost Accounting Ledger"),
reference_doctype=repost_doc.doctype,
reference_name=repost_doc.name,
)
frappe.db.set_value(
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
)
if commit:
frappe.db.commit()
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
_repost_invoices(doc, delete_cancelled_entries)
elif doc.doctype == "Purchase Receipt":
_repost_purchase_receipt(doc, delete_cancelled_entries)
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
_repost_pe_je(doc, delete_cancelled_entries)
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
def _repost_invoices(invoice_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
invoice_doc.docstatus = 2
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
invoice_doc.docstatus = 1
if invoice_doc.doctype == "Sales Invoice":
invoice_doc.force_set_against_income_account()
else:
invoice_doc.force_set_against_expense_account()
invoice_doc.make_gl_entries()
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
receipt_doc.docstatus = 2
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
receipt_doc.docstatus = 1
receipt_doc.make_gl_entries(from_repost=True)
def _repost_pe_je(entry_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
entry_doc.make_gl_entries(cancel=1)
entry_doc.make_gl_entries()
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
@frappe.whitelist()
def start_repost(account_repost_doc: str | None = None) -> None:
from erpnext.accounts.general_ledger import make_reverse_gl_entries
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
if not delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
repost_doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
repost_doc.make_gl_entries()
frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting
repost_doc.validate_for_deferred_accounting()
for x in repost_doc.vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
if repost_doc.delete_cancelled_entries:
frappe.db.delete(
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
frappe.db.delete(
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
frappe.db.delete(
"Advance Payment Ledger Entry",
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
)
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
doc.docstatus = 1
if doc.doctype == "Sales Invoice":
doc.force_set_against_income_account()
else:
doc.force_set_against_expense_account()
doc.make_gl_entries()
elif doc.doctype == "Purchase Receipt":
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
doc.docstatus = 1
doc.make_gl_entries(from_repost=True)
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
if not repost_doc.delete_cancelled_entries:
doc.make_gl_entries(1)
doc.make_gl_entries()
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
if not repost_doc.delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
doc.make_gl_entries()
def get_allowed_types_from_settings(child_doc: bool = False):

View File

@@ -1,16 +0,0 @@
frappe.listview_settings["Repost Accounting Ledger"] = {
add_fields: ["status"],
// drafts and cancelled documents are coloured by the framework before it gets here
get_indicator: function (doc) {
if (!doc.status) return;
const status_color = {
Queued: "yellow",
"In Progress": "blue",
"Partially Reposted": "orange",
Completed: "green",
Failed: "red",
};
return [__(doc.status), status_color[doc.status] || "gray", "status,=," + doc.status];
},
};

View File

@@ -1,35 +1,20 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe
from frappe import qb
from frappe.query_builder.functions import Sum
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, nowdate, today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
_lock_vouchers,
_record_repost_failure,
_repost_allowed_hook_doctypes,
_repost_job_id,
_repost_vouchers,
repost,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
SIMULATED_FAILURE = "Simulated repost failure"
class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
def setUp(self):
@@ -41,8 +26,8 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
def tearDown(self):
frappe.db.rollback()
def make_invoice(self, **kwargs):
return create_sales_invoice(
def test_01_basic_functions(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
@@ -50,71 +35,8 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
**kwargs,
)
def make_invoice_and_payment(self):
si = self.make_invoice()
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
return si, pe
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.delete_cancelled_entries = delete_cancelled_entries
for voucher in vouchers:
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
ral.save()
if submit:
ral.submit()
ral.reload()
return ral
@contextmanager
def patched_repost(self, fail_for=()):
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
reposted = []
def repost_voucher(doc, delete_cancelled_entries):
reposted.append(doc.name)
if doc.doctype in fail_for:
frappe.throw(SIMULATED_FAILURE)
_repost_vouchers(doc, delete_cancelled_entries)
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
yield reposted
def make_period_closing_voucher(self):
fy = get_fiscal_year(today(), company=self.company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": self.company,
"fiscal_year": fy[0],
"cost_center": self.cost_center,
"closing_account_head": self.retained_earnings,
"remarks": "test",
}
)
return pcv.save().submit()
def get_gl_totals(self, voucher_no, is_cancelled=0):
gl = qb.DocType("GL Entry")
return (
qb.from_(gl)
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
.run()
)[0]
def test_01_basic_functions(self):
si = self.make_invoice()
preq = frappe.get_doc(
make_payment_request(
dt=si.doctype,
@@ -148,24 +70,51 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to})
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
gl = qb.DocType("GL Entry")
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Assert incorrect ledger balance
self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
self.assertNotEqual(res[0], (si.name, 100, 100))
# Submit repost document
ral.save().submit()
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Ledger should reflect correct amount post repost
self.assertEqual(self.get_gl_totals(si.name), (100, 100))
self.assertEqual(res[0], (si.name, 100, 100))
def test_02_deferred_accounting_valiations(self):
si = self.make_invoice(do_not_submit=True)
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
do_not_submit=True,
)
si.items[0].enable_deferred_revenue = True
si.items[0].deferred_revenue_account = self.deferred_revenue
si.items[0].service_start_date = nowdate()
si.items[0].service_end_date = add_days(nowdate(), 90)
si.save().submit()
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_04_pcv_validation(self):
@@ -173,29 +122,86 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == self.company).run()
si = self.make_invoice()
pcv = self.make_period_closing_voucher()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
)
fy = get_fiscal_year(today(), company=self.company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": self.company,
"fiscal_year": fy[0],
"cost_center": self.cost_center,
"closing_account_head": self.retained_earnings,
"remarks": "test",
}
)
pcv.save().submit()
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
pcv.reload()
pcv.cancel()
pcv.delete()
def test_03_deletion_flag_and_preview_function(self):
si, pe = self.make_invoice_and_payment()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
# with deletion flag set
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
def test_05_without_deletion_flag(self):
si, pe = self.make_invoice_and_payment()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
# without deletion flag set
self.create_repost_doc([si, pe], submit=True)
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.delete_cancelled_entries = False
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
@@ -241,7 +247,11 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
another_provisional_account,
)
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
repost_doc = frappe.new_doc("Repost Accounting Ledger")
repost_doc.company = self.company
repost_doc.delete_cancelled_entries = True
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
repost_doc.save().submit()
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles_after_repost = [
@@ -262,279 +272,6 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
company.default_provisional_account = None
company.save()
def test_07_voucher_validations(self):
submitted_si = self.make_invoice()
draft_si = self.make_invoice(do_not_submit=True)
cancelled_si = self.make_invoice()
cancelled_si.cancel()
for vouchers, exception, message in (
([], frappe.ValidationError, "Add atleast one voucher"),
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
# cancelled vouchers don't make it past link validation
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
):
with self.subTest(vouchers=[x.name for x in vouchers]):
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
self.create_repost_doc([submitted_si])
def test_08_voucher_count_limit(self):
si, pe = self.make_invoice_and_payment()
another_si = self.make_invoice()
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
self.create_repost_doc([si, pe])
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot repost more than 2 vouchers",
self.create_repost_doc,
[si, pe, another_si],
)
def test_09_status_lifecycle(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
self.assertEqual(ral.status, "")
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Completed")
self.assertFalse(ral.error_log)
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
ral.cancel()
ral.reload()
self.assertEqual(ral.status, "Cancelled")
# the `discard` flow (and the `on_discard` hook it triggers) only exists on v16,
# so there is nothing to assert here on v15
def test_10_start_repost_guards(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
ral.submit()
ral.reload()
self.assertRaisesRegex(
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
)
# a document left behind by a worker that died mid-repost
ral.db_set("status", "In Progress")
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
self.assertRaisesRegex(
frappe.ValidationError, "still in progress in background", ral.start_repost
)
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
# `cancel` flips docstatus in memory before running `before_cancel`
ral.reload()
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
# the job is gone, so `In Progress` must not keep the document stuck
ral.start_repost()
ral.reload()
self.assertEqual(ral.status, "Completed")
def test_11_repost_job_is_tied_to_the_document(self):
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
ral.start_repost()
kwargs = enqueue.call_args.kwargs
self.assertEqual(kwargs["repost_doc_name"], ral.name)
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
# a second start cannot queue a second job for the same document
self.assertTrue(kwargs["deduplicate"])
def test_12_voucher_failures_are_isolated_and_retried(self):
si, pe = self.make_invoice_and_payment()
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
# the deletion flag drops the existing entries before reposting them
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Partially Reposted")
si_row, pe_row = ral.vouchers
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
self.assertFalse(si_row.traceback)
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
# the failed voucher is rolled back to its savepoint, so its entries are back
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
# alone entirely: they are not locked or loaded either
with (
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
self.patched_repost() as retried,
):
ral.start_repost()
self.assertEqual(retried, [pe.name])
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
ral.reload()
self.assertEqual(ral.status, "Completed")
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
def test_13_status_of_a_run_that_could_not_finish(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
# the job dies after the loop committed the invoice, e.g. killed or timed out
try:
frappe.throw(SIMULATED_FAILURE)
except frappe.ValidationError:
_record_repost_failure(ral)
ral.reload()
# progress already committed must not be reported as a total failure
self.assertEqual(ral.status, "Partially Reposted")
self.assertIn(SIMULATED_FAILURE, ral.error_log)
self.assertTrue(
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
)
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_14_period_closed_after_the_repost_was_started(self):
gl = qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == self.company).run()
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
ral.vouchers[0].db_set("status", "Pending")
# the period is closed between the repost being started and the job running
self.make_period_closing_voucher()
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
ral.reload()
self.assertEqual(ral.status, "Failed")
self.assertIn("Closed fiscal year", ral.error_log)
# the ledger is left exactly as it was
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
self.assertEqual(ral.vouchers[0].status, "Pending")
def test_15_failed_repost_skips_cancelled_voucher(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
with self.patched_repost(fail_for=["Sales Invoice"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Failed")
si.reload()
si.cancel()
ral.start_repost()
ral.reload()
# nothing was reposted, but there is nothing left to repost either
self.assertEqual(ral.status, "Completed")
self.assertEqual(ral.vouchers[0].status, "Skipped")
self.assertFalse(ral.vouchers[0].traceback)
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
# a concurrent repost holding the lock on the second voucher
locked_pe = frappe.get_doc(pe.doctype, pe.name)
locked_pe.lock()
try:
self.assertRaises(frappe.DocumentLockedError, ral.submit)
# vouchers locked before the failure are released again
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
finally:
locked_pe.unlock()
def test_17_journal_entry_repost(self):
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
je = frappe.get_doc("Journal Entry", je.name)
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
# without the deletion flag the 2 original entries are marked as cancelled,
# along with the 2 reverse entries booked against them
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
ral = self.create_repost_doc(
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
)
self.assertEqual(ral.status, "Completed")
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
self.assertEqual(
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
cancelled_entries,
)
def test_18_hook_allowed_doctype_repost(self):
class VoucherWithCancelArg:
doctype = "Test Repost Voucher"
name = "TRV-00001"
def __init__(self):
self.calls = []
def make_gl_entries(self, cancel=0):
self.calls.append(cancel)
class VoucherWithoutCancelArg(VoucherWithCancelArg):
def make_gl_entries(self):
self.calls.append("repost")
# vouchers that can reverse their own entries are asked to do so first
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
self.assertEqual(doc.calls, [1, 0])
# nothing to reverse when the old entries are deleted
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
self.assertEqual(doc.calls, [0])
# the rest fall back to the generic reversal
doc = VoucherWithoutCancelArg()
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
self.assertEqual(doc.calls, ["repost"])
def update_repost_settings():
allowed_types = [

View File

@@ -1,6 +1,5 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2023-07-04 14:14:01.243848",
"doctype": "DocType",
@@ -8,63 +7,28 @@
"engine": "InnoDB",
"field_order": [
"voucher_type",
"column_break_ndex",
"voucher_no",
"reposting_status_section",
"status",
"traceback"
"voucher_no"
],
"fields": [
{
"columns": 5,
"fieldname": "voucher_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Voucher Type",
"options": "DocType",
"reqd": 1
"options": "DocType"
},
{
"fieldname": "column_break_ndex",
"fieldtype": "Column Break"
},
{
"columns": 5,
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Voucher No",
"options": "voucher_type",
"reqd": 1
},
{
"fieldname": "reposting_status_section",
"fieldtype": "Section Break",
"label": "Reposting Status"
},
{
"columns": 2,
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"no_copy": 1,
"options": "Pending\nReposted\nSkipped\nFailed",
"read_only": 1
},
{
"fieldname": "traceback",
"fieldtype": "Code",
"label": "Traceback",
"no_copy": 1,
"read_only": 1
"options": "voucher_type"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-29 02:41:00.000000",
"modified": "2023-07-04 14:15:51.165584",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger Items",
@@ -73,4 +37,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -17,10 +17,8 @@ class RepostAccountingLedgerItems(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
traceback: DF.Code | None
voucher_no: DF.DynamicLink
voucher_type: DF.Link
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
# end: auto-generated types
pass

View File

@@ -455,8 +455,8 @@ class SalesInvoice(SellingController):
self.calculate_taxes_and_totals()
def before_save(self):
self.set_paid_amount()
self.set_account_for_mode_of_payment()
self.set_paid_amount()
def before_submit(self):
self.add_remarks()
@@ -791,13 +791,6 @@ class SalesInvoice(SellingController):
def set_paid_amount(self):
paid_amount = 0.0
base_paid_amount = 0.0
if not cint(self.is_pos) and self.is_return:
self.set("payments", [])
self.paid_amount = paid_amount
self.base_paid_amount = base_paid_amount
return
for data in self.payments:
data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount"))
paid_amount += data.amount
@@ -2884,6 +2877,8 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
from frappe.model.mapper import get_mapped_doc
def postprocess_dunning(source, target):
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
if dunning_type:
dunning_type = frappe.get_doc("Dunning Type", dunning_type)
@@ -2892,8 +2887,14 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
target.dunning_fee = dunning_type.dunning_fee
target.income_account = dunning_type.income_account
target.cost_center = dunning_type.cost_center
target.language = source.language
target.get_dunning_letter_text()
letter_text = get_dunning_letter_text(
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
)
if letter_text:
target.body_text = letter_text.get("body_text")
target.closing_text = letter_text.get("closing_text")
target.language = letter_text.get("language")
# update outstanding from doc
if source.payment_schedule and len(source.payment_schedule) == 1:

View File

@@ -1049,21 +1049,6 @@ class TestSalesInvoice(FrappeTestCase):
self.assertEqual(pos_return.get("payments")[0].amount, -500)
self.assertEqual(pos_return.get("payments")[1].amount, -500)
def test_non_pos_return_clears_payment_rows(self):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
si = create_sales_invoice(do_not_save=True)
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
si.insert()
si.submit()
si_return = make_sales_return(si.name)
si_return.insert()
self.assertEqual(si_return.is_pos, 0)
self.assertEqual(si_return.get("payments"), [])
self.assertEqual(si_return.paid_amount, 0)
def test_pos_change_amount(self):
make_pos_profile(
company="_Test Company with perpetual inventory",
@@ -3700,51 +3685,6 @@ class TestSalesInvoice(FrappeTestCase):
self.assertTrue("cannot overbill" in str(err.exception).lower())
dn.cancel()
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_is_blocked(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
service_item = create_item(
"_Test Service Item Non Stock SI",
is_stock_item=0,
).name
so = make_sales_order(item_code=service_item, qty=5, rate=100)
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@change_settings("Accounts Settings", {"over_billing_allowance": 0})
def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self):
from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
service_item = create_item(
"_Test Service Item Non Stock SI Quot",
is_stock_item=0,
).name
quotation = make_quotation(item_code=service_item, qty=5, rate=100)
so = make_so_from_quotation(quotation.name)
so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7)
so.insert()
so.submit()
si = make_si_from_so(so.name)
si.items[0].qty = 10 # overbill by 100 %
si.save()
with self.assertRaises(frappe.ValidationError):
si.submit()
@change_settings(
"Accounts Settings",
{

View File

@@ -697,15 +697,13 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, adv_adj)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -772,7 +770,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date
@@ -804,24 +802,13 @@ def check_freezing_date(posting_date, adv_adj=False):
)
def validate_opening_entry_against_pcv(company):
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
def validate_against_pcv(is_opening, posting_date, company):
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
_(
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
).format(
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
+ _("Read the docs")
+ "</a>"
),
_("Opening Entry can not be created after Period Closing Voucher is created."),
title=_("Invalid Opening Entry"),
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening:
validate_opening_entry_against_pcv(company)
# Local import so you don't have to touch file-level imports
from frappe.query_builder.functions import Max

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Purchase Invoice",
"dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Bills",
"modified": "2026-06-01 12:00:00.000000",
"modified": "2024-11-20 19:08:37.043777",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Payment",
"modified": "2026-06-01 12:00:00.000000",
"modified": "2020-07-22 13:06:20.237689",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Payment",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Invoice",
"dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Bills",
"modified": "2026-06-01 12:00:00.000000",
"modified": "2020-07-22 13:07:19.633101",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Payment",
"modified": "2026-06-01 12:00:00.000000",
"modified": "2020-07-22 12:49:34.942896",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Payment",

View File

@@ -431,17 +431,6 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
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:
@@ -452,51 +441,46 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
"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
@@ -853,7 +837,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_list(
companies = frappe.get_all(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)
@@ -938,15 +922,6 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
if party_type == "Supplier":
info["total_unpaid"] = -1 * info["total_unpaid"]
if info["total_unpaid"] < 0:
info["balance_label"] = (
"Total Advance Paid" if party_type == "Supplier" else "Total Advance Received"
)
info["balance_amount"] = abs(info["total_unpaid"])
else:
info["balance_label"] = "Total Unpaid"
info["balance_amount"] = info["total_unpaid"]
company_wise_info.append(info)
return company_wise_info

View File

@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
},
{
fieldname: "report_date",
label: __("Report Date"),
label: __("Posting Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldtype: "Select",
options: "Report Date\nToday",
options: "Report Date\nToday Date",
default: "Report Date",
},
{

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2026-07-01 13:37:41.185347",
"modified": "2017-02-24 20:09:46.150861",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -15,11 +15,6 @@
"ref_doctype": "Purchase Invoice",
"report_name": "Accounts Payable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -33,7 +28,5 @@
{
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}
]
}

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
},
{
fieldname: "report_date",
label: __("Report Date"),
label: __("Posting Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldtype: "Select",
options: "Report Date\nToday",
options: "Report Date\nToday Date",
default: "Report Date",
},
{

View File

@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
},
{
fieldname: "report_date",
label: __("Report Date"),
label: __("Posting Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldtype: "Select",
options: "Report Date\nToday",
options: "Report Date\nToday Date",
default: "Report Date",
},
{

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2026-07-01 13:37:44.167999",
"modified": "2017-03-06 05:52:06.235584",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Receivable",
@@ -15,11 +15,6 @@
"ref_doctype": "Sales Invoice",
"report_name": "Accounts Receivable",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "Payment Ledger Entry"
}
],
"roles": [
{
"role": "Accounts Manager"
@@ -27,7 +22,5 @@
{
"role": "Accounts User"
}
],
"snapshot_report": 0,
"timeout": 0
}
]
}

View File

@@ -55,7 +55,8 @@ class ReceivablePayableReport:
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
if "calculate_ageing_with" not in self.filters
or self.filters.calculate_ageing_with == "Today Date"
else self.filters.report_date
)
@@ -262,12 +263,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

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
},
{
fieldname: "report_date",
label: __("Report Date"),
label: __("Posting Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
default: "Due Date",
},
{
fieldname: "age_as_on",
label: __("Age as on"),
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldtype: "Select",
options: "Report Date\nToday",
options: "Report Date\nToday Date",
default: "Report Date",
},
{

View File

@@ -6,7 +6,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2026-06-22 13:38:25.236839",
"modified": "2018-09-07 12:18:21.850851",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Balance Sheet",
@@ -15,11 +15,6 @@
"ref_doctype": "GL Entry",
"report_name": "Balance Sheet",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"roles": [
{
"role": "Accounts User"
@@ -30,7 +25,5 @@
{
"role": "Auditor"
}
],
"synced_report": 0,
"timeout": 0
}
]
}

View File

@@ -4,23 +4,14 @@
import frappe
from frappe import _
from frappe.utils import add_days, cint, flt
from frappe.utils import cint, flt
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
calculate_values,
compute_growth_view_data,
filter_accounts,
filter_out_zero_value_rows,
get_accounting_entries,
get_accounts,
get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
prepare_data,
)
@@ -268,196 +259,3 @@ def get_chart_data(filters, chart_columns, asset, liability, equity, currency):
chart["currency"] = currency
return chart
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
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 = 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(
"Company", filters.company, "default_currency"
)
asset = _get_data_duckdb(conn, filters, "Asset", "Debit", period_list)
liability = _get_data_duckdb(conn, filters, "Liability", "Credit", period_list)
equity = _get_data_duckdb(conn, filters, "Equity", "Credit", period_list)
provisional_profit_loss, total_credit = get_provisional_profit_loss(
asset, liability, equity, period_list, filters.company, currency
)
message, opening_balance = check_opening_balance(asset, liability, equity)
data = []
data.extend(asset or [])
data.extend(liability or [])
data.extend(equity or [])
if opening_balance and round(opening_balance, 2) != 0:
unclosed = {
"account_name": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"account": "'" + _("Unclosed Fiscal Years Profit / Loss (Credit)") + "'",
"warn_if_negative": True,
"currency": currency,
}
for period in period_list:
unclosed[period.key] = opening_balance
if provisional_profit_loss:
provisional_profit_loss[period.key] = provisional_profit_loss[period.key] - opening_balance
unclosed["total"] = opening_balance
data.append(unclosed)
if provisional_profit_loss:
data.append(provisional_profit_loss)
if total_credit:
data.append(total_credit)
columns = get_columns(
filters.periodicity, period_list, filters.accumulated_values, company=filters.company
)
chart = get_chart_data(filters, period_list, asset, liability, equity, currency)
report_summary, primitive_summary = get_report_summary(
period_list, asset, liability, equity, provisional_profit_loss, currency, filters
)
if filters.get("selected_view") == "Growth":
compute_growth_view_data(data, period_list)
return columns, data, message, chart, report_summary, primitive_summary
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
accounts = get_accounts(filters.company, root_type)
if not accounts:
return None
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
company_currency = get_appropriate_currency(filters.company, filters)
gl_entries_by_account = {}
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type)
calculate_values(
accounts_by_name,
gl_entries_by_account,
period_list,
filters.accumulated_values,
False,
)
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
out = prepare_data(
accounts,
balance_must_be,
period_list,
company_currency,
accumulated_values=filters.accumulated_values,
)
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
if out:
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
return out
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account, root_type):
from erpnext.accounts.report.trial_balance.trial_balance import (
_extra_gl_conditions,
_fetch_gl_rows_duckdb,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
company = filters.company
year_start_date = period_list[0]["year_start_date"]
last_to_date = period_list[-1]["to_date"]
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
if not leaf_accounts:
return
opening_from_date = None
ignore_opening_entries = False
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
if not ignore_closing_balances:
last_pcv_list = frappe.db.get_all(
"Period Closing Voucher",
filters={
"docstatus": 1,
"company": company,
"period_end_date": ("<", filters.get("period_start_date") or year_start_date),
},
fields=["period_end_date", "name"],
order_by="period_end_date desc",
limit=1,
)
if last_pcv_list:
last_pcv = last_pcv_list[0]
pcv_entries = get_accounting_entries(
"Account Closing Balance",
None,
last_to_date,
filters,
root_type=root_type,
ignore_closing_entries=False,
period_closing_voucher=last_pcv.name,
)
if filters.get("presentation_currency"):
convert_to_presentation_currency(pcv_entries, get_currency(filters))
for entry in pcv_entries:
gl_entries_by_account.setdefault(entry.account, []).append(entry)
opening_from_date = add_days(last_pcv.period_end_date, 1)
ignore_opening_entries = True
extra_cond, extra_params = _extra_gl_conditions(filters)
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
base_conds = [
"company = ?",
"is_cancelled = 0",
f"account IN ({account_placeholders})",
]
base_params = [company, *leaf_accounts]
if ignore_opening_entries and not ignore_is_opening:
base_conds.append("is_opening = 'No'")
base_conds.extend(extra_cond)
base_params.extend(extra_params)
# Opening GL entries from DuckDB (entries before year_start_date)
open_conds = [*base_conds, "posting_date < ?"]
open_params = [*base_params, year_start_date]
if opening_from_date:
open_conds = [*open_conds, "posting_date >= ?"]
open_params = [*open_params, opening_from_date]
opening_entries = _fetch_gl_rows_duckdb(conn, open_conds, open_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(opening_entries, get_currency(filters))
synthetic_open_date = add_days(year_start_date, -1)
for entry in opening_entries:
entry.posting_date = synthetic_open_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)
# Period GL entries from DuckDB (one aggregated query per period)
for period in period_list:
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
period_params = [*base_params, period.from_date, period.to_date]
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(period_entries, get_currency(filters))
for entry in period_entries:
entry.posting_date = period.to_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)

View File

@@ -28,16 +28,16 @@
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
{% } %}
</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
</tr>
{% } else { %}
<tr>
<td></td>
<td></td>
<td>{%= data[i]["payment_entry"] %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
</tr>
{% } %}
{% } %}

View File

@@ -73,7 +73,6 @@ def execute(filters=None):
"parent_section": None,
"indent": 0.0,
"section": cash_flow_section["section_header"],
"currency": company_currency,
}
)

View File

@@ -6,15 +6,11 @@
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"idx": 3,
"is_standard": "Yes",
"modified": "2026-07-01 13:36:06.682661",
"letterhead": null,
"modified": "2025-11-05 15:47:59.597853",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General Ledger",
@@ -34,6 +30,5 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}

View File

@@ -792,288 +792,3 @@ def get_columns(filters):
columns.extend([{"label": _("Remarks"), "fieldname": "remarks", "width": 400}])
return columns
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):
return _execute_with_duckdb_conn(filters, conn)
frappe.throw(_("General Ledger requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
def _execute_with_duckdb_conn(filters, conn):
if not filters:
return [], []
account_details = {}
if filters.get("print_in_account_currency") and not filters.get("account"):
frappe.throw(_("Select an account to print in account currency"))
for acc in frappe.get_all("Account", fields=["name", "is_group"]):
account_details.setdefault(acc.name, acc)
if filters.get("party"):
filters.party = frappe.parse_json(filters.get("party"))
validate_filters(filters, account_details)
validate_party(filters)
filters = set_account_currency(filters)
columns = get_columns(filters)
res = get_result_duckdb(filters, account_details, conn)
return columns, res
def get_result_duckdb(filters, account_details, conn):
accounting_dimensions = []
if filters.get("include_dimensions"):
accounting_dimensions = get_accounting_dimensions()
gl_entries = get_gl_entries_duckdb(filters, accounting_dimensions, conn)
data = get_data_with_opening_closing(filters, account_details, accounting_dimensions, gl_entries)
return get_result_as_list(data, filters)
def get_gl_entries_duckdb(filters, accounting_dimensions, conn):
currency_map = get_currency(filters)
col_names = [
"gl_entry",
"posting_date",
"account",
"party_type",
"party",
"voucher_type",
"voucher_subtype",
"voucher_no",
"cost_center",
"project",
"against_voucher_type",
"against_voucher",
"account_currency",
"against",
"is_opening",
"creation",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
]
select_exprs = [
"name",
"posting_date",
"account",
"party_type",
"party",
"voucher_type",
"voucher_subtype",
"voucher_no",
"cost_center",
"project",
"against_voucher_type",
"against_voucher",
"account_currency",
"against",
"is_opening",
"creation",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
]
if filters.get("show_remarks"):
remarks_length = frappe.get_single_value("Accounts Settings", "general_ledger_remarks_length")
if remarks_length:
select_exprs.append(f"substr(remarks, 1, {int(remarks_length)})")
else:
select_exprs.append("remarks")
col_names.append("remarks")
if filters.get("add_values_in_transaction_currency"):
select_exprs += [
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_currency",
]
col_names += [
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_currency",
]
if accounting_dimensions:
select_exprs += accounting_dimensions
col_names += accounting_dimensions
order_by = "posting_date, account, creation"
if filters.get("include_dimensions"):
order_by = "posting_date, creation"
if filters.get("categorize_by") == "Categorize by Voucher":
order_by = "posting_date, voucher_type, voucher_no"
if filters.get("categorize_by") == "Categorize by Account":
order_by = "account, posting_date, creation"
if filters.get("include_default_book_entries"):
filters["company_fb"] = frappe.get_cached_value(
"Company", filters.get("company"), "default_finance_book"
)
conditions, params = _build_gl_conditions_duckdb(filters)
select_clause = ", ".join(select_exprs)
sql = f'SELECT {select_clause} FROM "tabGL Entry" WHERE {" AND ".join(conditions)} ORDER BY {order_by}'
rows = conn.execute(sql, params).fetchall()
gl_entries = [frappe._dict(zip(col_names, row, strict=False)) for row in rows]
party_name_map = get_party_name_map()
for gl_entry in gl_entries:
if gl_entry.party_type and gl_entry.party:
gl_entry.party_name = party_name_map.get(gl_entry.party_type, {}).get(gl_entry.party)
if filters.get("presentation_currency"):
return convert_to_presentation_currency(gl_entries, currency_map, filters)
return gl_entries
def _build_gl_conditions_duckdb(filters):
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
conditions = ["company = ?"]
params = [filters.company]
if filters.get("account"):
filters.account = get_accounts_with_children(filters.account)
if filters.account:
conditions.append(f"account IN ({', '.join(['?'] * len(filters.account))})")
params.extend(filters.account)
if filters.get("cost_center"):
filters.cost_center = get_cost_centers_with_children(filters.cost_center)
conditions.append(f"cost_center IN ({', '.join(['?'] * len(filters.cost_center))})")
params.extend(filters.cost_center)
if filters.get("voucher_no"):
conditions.append("voucher_no = ?")
params.append(filters.voucher_no)
if filters.get("against_voucher_no"):
conditions.append("against_voucher = ?")
params.append(filters.against_voucher_no)
if filters.get("ignore_err"):
err_journals = frappe.db.get_all(
"Journal Entry",
filters={
"company": filters.get("company"),
"docstatus": 1,
"voucher_type": ("in", ["Exchange Rate Revaluation", "Exchange Gain Or Loss"]),
},
pluck="name",
)
if err_journals:
filters.update({"voucher_no_not_in": err_journals})
if filters.get("ignore_cr_dr_notes"):
system_generated = frappe.db.get_all(
"Journal Entry",
filters={
"company": filters.get("company"),
"docstatus": 1,
"voucher_type": ("in", ["Credit Note", "Debit Note"]),
"is_system_generated": 1,
},
pluck="name",
)
if system_generated:
vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + system_generated
filters.update({"voucher_no_not_in": vouchers_to_ignore})
if filters.get("voucher_no_not_in"):
vouchers = filters.voucher_no_not_in
conditions.append(f"voucher_no NOT IN ({', '.join(['?'] * len(vouchers))})")
params.extend(vouchers)
if filters.get("categorize_by") == "Categorize by Party" and not filters.get("party_type"):
conditions.append("party_type IN ('Customer', 'Supplier')")
if filters.get("party_type"):
conditions.append("party_type = ?")
params.append(filters.party_type)
if filters.get("party"):
conditions.append(f"party IN ({', '.join(['?'] * len(filters.party))})")
params.extend(filters.party)
# from_date: skip when filtering by account/party to allow opening balance calc in Python
if filters.get("disable_opening_balance_calculation"):
if not ignore_is_opening:
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date >= ?")
params.append(filters.from_date)
elif not (
filters.get("account")
or filters.get("party")
or filters.get("categorize_by") in ["Categorize by Account", "Categorize by Party"]
):
if not ignore_is_opening:
conditions.append("(posting_date >= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date >= ?")
params.append(filters.from_date)
if not ignore_is_opening:
conditions.append("(posting_date <= ? OR is_opening = 'Yes')")
else:
conditions.append("posting_date <= ?")
params.append(filters.to_date)
if filters.get("project"):
conditions.append(f"project IN ({', '.join(['?'] * len(filters.project))})")
params.extend(filters.project)
company_fb = filters.get("company_fb") or frappe.get_cached_value(
"Company", filters.company, "default_finance_book"
)
if filters.get("include_default_book_entries"):
if filters.get("finance_book"):
if company_fb and cstr(filters.finance_book) != cstr(company_fb):
frappe.throw(
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
)
fb_vals = [cstr(filters.finance_book), ""]
else:
fb_vals = [cstr(company_fb), ""]
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_vals))}) OR finance_book IS NULL)")
params.extend(fb_vals)
else:
if filters.get("finance_book"):
conditions.append("(finance_book IN (?, '') OR finance_book IS NULL)")
params.append(cstr(filters.finance_book))
else:
conditions.append("(finance_book IN ('') OR finance_book IS NULL)")
if not filters.get("show_cancelled_entries"):
conditions.append("is_cancelled = 0")
accounting_dimensions_list = get_accounting_dimensions(as_list=False)
if accounting_dimensions_list:
for dimension in accounting_dimensions_list:
if not dimension.disabled and dimension.document_type != "Finance Book":
if filters.get(dimension.fieldname):
if frappe.get_cached_value("DocType", dimension.document_type, "is_tree"):
filters[dimension.fieldname] = get_dimension_with_children(
dimension.document_type, filters.get(dimension.fieldname)
)
vals = (
filters[dimension.fieldname]
if isinstance(filters[dimension.fieldname], list)
else [filters[dimension.fieldname]]
)
conditions.append(f"{dimension.fieldname} IN ({', '.join(['?'] * len(vals))})")
params.extend(vals)
return conditions, params

View File

@@ -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"]]
@@ -564,12 +562,7 @@ class GrossProfitGenerator:
row.base_amount = packed_item.base_amount
# get buying amount
if row.is_debit_note:
# Rate adjustment debit notes have no stock movement, so buying amount is zero
if not grouped_by_invoice:
row.qty = 0
row.buying_amount = 0
elif row.item_code in product_bundles:
if row.item_code in product_bundles:
row.buying_amount = flt(
self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]),
self.currency_precision,
@@ -932,7 +925,6 @@ class GrossProfitGenerator:
SalesInvoice.customer_group,
SalesInvoice.customer_name,
SalesInvoice.territory,
SalesInvoice.is_debit_note,
SalesInvoiceItem.item_code,
SalesInvoice.base_net_total.as_("invoice_base_net_total"),
SalesInvoiceItem.item_name,
@@ -1112,7 +1104,6 @@ class GrossProfitGenerator:
"posting_time": row.posting_time,
"project": row.project,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"customer": row.customer,
"customer_group": row.customer_group,
"customer_name": row.customer_name,
@@ -1151,7 +1142,6 @@ class GrossProfitGenerator:
"description": item.description,
"warehouse": item.warehouse or row.warehouse,
"update_stock": row.update_stock,
"is_debit_note": row.is_debit_note,
"item_group": "",
"brand": "",
"dn_detail": row.dn_detail,

View File

@@ -727,160 +727,6 @@ class TestGrossProfit(FrappeTestCase):
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None):
"""Create a rate adjustment debit note with no stock movement."""
dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True)
if item_code:
dn.items[0].item_code = item_code
dn.items[0].item_name = item_code
dn.is_debit_note = 1
dn.return_against = against_invoice.name
dn.items[0].allow_zero_valuation_rate = 1
return dn.save().submit()
def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self):
"""
Rate adjustment debit note (is_debit_note=1) should show buying_amount=0
since there is no stock movement. Gross profit equals the adjustment amount
and gross profit % equals 100%.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
dn_item_rows = [
x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0
]
self.assertEqual(len(dn_item_rows), 1)
dn_row = dn_item_rows[0]
self.assertEqual(dn_row.buying_amount, 0.0)
self.assertEqual(dn_row.selling_amount, 20.0)
self.assertEqual(dn_row.gross_profit, 20.0)
self.assertEqual(dn_row["gross_profit_%"], 100.0)
def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self):
"""
The original invoice's GP should be derived solely from its own selling
amount and COGS — the rate adjustment debit note must not alter it.
"""
make_stock_entry(
company=self.company,
item_code=self.item,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True)
sinv.update_stock = 1
sinv = sinv.save().submit()
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Invoice",
)
columns, data = execute(filters=filters)
sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0]
self.assertEqual(len(sinv_item_rows), 1)
sinv_row = sinv_item_rows[0]
self.assertEqual(sinv_row.selling_amount, 200.0)
self.assertEqual(sinv_row.buying_amount, 100.0)
self.assertEqual(sinv_row.gross_profit, 100.0)
self.assertEqual(sinv_row["gross_profit_%"], 50.0)
def test_debit_note_qty_not_inflated_in_grouped_report(self):
"""
When grouped by Item Code, the debit note (qty=0) must not inflate
the group's qty or buying_amount. The selling amount and average
selling rate correctly reflect the rate adjustment.
"""
item = create_item("_Test Rate Adjustment Debit Note Item")
make_stock_entry(
company=self.company,
item_code=item.item_code,
target=self.warehouse,
qty=1,
basic_rate=100,
)
sinv = create_sales_invoice(
qty=1,
rate=200,
company=self.company,
customer=self.customer,
item_code=item.item_code,
item_name=item.item_code,
cost_center=self.cost_center,
warehouse=self.warehouse,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
update_stock=1,
currency="INR",
income_account=self.income_account,
expense_account=self.expense_account,
)
self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code)
filters = frappe._dict(
company=self.company,
from_date=nowdate(),
to_date=nowdate(),
group_by="Item Code",
)
columns, data = execute(filters=filters)
# group_by="Item Code" column order:
# [item_code, item_name, brand, description, qty, base_rate,
# buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency]
item_row = next((row for row in data if row[0] == item.item_code), None)
self.assertIsNotNone(item_row)
qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = (
item_row[4],
item_row[5],
item_row[8],
item_row[7],
item_row[9],
item_row[10],
)
self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated
self.assertEqual(buying_amount, 100.0) # only original invoice COGS
self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment)
self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1
self.assertEqual(gross_profit, 120.0) # 220 - 100
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):

View File

@@ -7,7 +7,7 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2026-07-01 13:36:14.934965",
"modified": "2017-02-24 20:12:40.282376",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss Statement",
@@ -15,12 +15,6 @@
"ref_doctype": "GL Entry",
"report_name": "Profit and Loss Statement",
"report_type": "Script Report",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"filters": [],
"roles": [
{
"role": "Accounts User"
@@ -31,7 +25,5 @@
{
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}
]
}

View File

@@ -7,20 +7,12 @@ from frappe import _
from frappe.utils import flt
from erpnext.accounts.report.financial_statements import (
accumulate_values_into_parents,
add_total_row,
calculate_values,
compute_growth_view_data,
compute_margin_view_data,
filter_accounts,
filter_out_zero_value_rows,
get_accounts,
get_appropriate_currency,
get_columns,
get_data,
get_filtered_list_for_consolidated_report,
get_period_list,
prepare_data,
)
@@ -201,125 +193,3 @@ def get_chart_data(filters, chart_columns, income, expense, net_profit_loss, cur
chart["currency"] = currency
return chart
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if not (conn := get_latest_sync("GL Entry")):
frappe.throw(
_("Profit and Loss Statement requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry"))
)
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,
)
income = _get_data_duckdb(conn, filters, "Income", "Credit", period_list)
expense = _get_data_duckdb(conn, filters, "Expense", "Debit", period_list)
net_profit_loss = get_net_profit_loss(
income, expense, period_list, filters.company, filters.presentation_currency
)
data = []
data.extend(income or [])
data.extend(expense or [])
if net_profit_loss:
data.append(net_profit_loss)
columns = get_columns(filters.periodicity, period_list, filters.accumulated_values, filters.company)
currency = filters.presentation_currency or frappe.get_cached_value(
"Company", filters.company, "default_currency"
)
chart = get_chart_data(filters, period_list, income, expense, net_profit_loss, currency)
report_summary, primitive_summary = get_report_summary(
period_list, filters.periodicity, income, expense, net_profit_loss, currency, filters
)
if filters.get("selected_view") == "Growth":
compute_growth_view_data(data, period_list)
if filters.get("selected_view") == "Margin":
compute_margin_view_data(data, period_list, filters.accumulated_values)
return columns, data, None, chart, report_summary, primitive_summary
def _get_data_duckdb(conn, filters, root_type, balance_must_be, period_list):
accounts = get_accounts(filters.company, root_type)
if not accounts:
return None
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
company_currency = get_appropriate_currency(filters.company, filters)
gl_entries_by_account = {}
_load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account)
calculate_values(
accounts_by_name,
gl_entries_by_account,
period_list,
filters.accumulated_values,
False,
)
accumulate_values_into_parents(accounts, accounts_by_name, period_list)
out = prepare_data(
accounts,
balance_must_be,
period_list,
company_currency,
accumulated_values=filters.accumulated_values,
)
out = filter_out_zero_value_rows(out, parent_children_map, filters.show_zero_values)
if out:
add_total_row(out, root_type, balance_must_be, period_list, company_currency)
return out
def _load_gl_entries_duckdb(conn, filters, period_list, accounts, gl_entries_by_account):
from erpnext.accounts.report.trial_balance.trial_balance import (
_extra_gl_conditions,
_fetch_gl_rows_duckdb,
)
from erpnext.accounts.report.utils import convert_to_presentation_currency, get_currency
company = filters.company
leaf_accounts = [acc.name for acc in accounts if not acc.is_group]
if not leaf_accounts:
return
extra_cond, extra_params = _extra_gl_conditions(filters)
account_placeholders = ", ".join(["?"] * len(leaf_accounts))
base_conds = [
"company = ?",
"is_cancelled = 0",
f"account IN ({account_placeholders})",
"voucher_type != 'Period Closing Voucher'",
]
base_params = [company, *leaf_accounts]
base_conds.extend(extra_cond)
base_params.extend(extra_params)
for period in period_list:
period_conds = [*base_conds, "posting_date >= ?", "posting_date <= ?"]
period_params = [*base_params, period.from_date, period.to_date]
period_entries = _fetch_gl_rows_duckdb(conn, period_conds, period_params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(period_entries, get_currency(filters))
for entry in period_entries:
entry.posting_date = period.to_date
gl_entries_by_account.setdefault(entry.account, []).append(entry)

View File

@@ -7,7 +7,6 @@ from frappe import _
from frappe.utils import flt, getdate
from pypika import Tuple
from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.accounts.utils import get_currency_precision
@@ -34,7 +33,9 @@ def execute(filters=None):
def validate_filters(filters):
"""Validate if dates are properly set"""
validate_mandatory_date_range(filters or {})
filters = frappe._dict(filters or {})
if filters.from_date > filters.to_date:
frappe.throw(_("From Date must be before To Date"))
def get_result(filters, tds_accounts, tax_category_map, net_total_map):

View File

@@ -5,7 +5,6 @@ from erpnext.accounts.report.tax_withholding_details.tax_withholding_details imp
get_result,
get_tds_docs,
)
from erpnext.accounts.report.utils import validate_mandatory_date_range
from erpnext.accounts.utils import get_fiscal_year
@@ -34,7 +33,8 @@ def execute(filters=None):
def validate_filters(filters):
"""Validate if dates are properly set and lie in the same fiscal year"""
validate_mandatory_date_range(filters)
if filters.from_date > filters.to_date:
frappe.throw(_("From Date must be before To Date"))
from_year = get_fiscal_year(filters.from_date)[0]
to_year = get_fiscal_year(filters.to_date)[0]

View File

@@ -7,18 +7,13 @@
"doctype": "Report",
"idx": 2,
"is_standard": "Yes",
"modified": "2026-07-01 17:32:21.801141",
"modified": "2017-02-24 20:12:33.520866",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Trial Balance",
"owner": "Administrator",
"ref_doctype": "GL Entry",
"report_name": "Trial Balance",
"doctype_to_sync": [
{
"doc_type": "GL Entry"
}
],
"report_type": "Script Report",
"roles": [
{
@@ -30,7 +25,5 @@
{
"role": "Auditor"
}
],
"snapshot_report": 0,
"timeout": 0
}
]
}

View File

@@ -510,215 +510,3 @@ def hide_group_accounts(data):
d.update(indent=0)
non_group_accounts_data.append(d)
return non_group_accounts_data
def execute_snapshot_report(filters):
from frappe.database.duckdb.database import get_latest_sync
if conn := get_latest_sync("GL Entry"):
validate_filters(filters)
columns = get_columns()
data = get_data_duckdb(filters, conn)
return columns, data
else:
frappe.throw(_("Trial Balance requires {0} to be synced to DuckDB").format(frappe.bold("GL Entry")))
def get_data_duckdb(filters, conn):
# accounts and all metadata via frappe.db — only GL Entry comes from DuckDB
accounts = frappe.db.sql(
"""select name, account_number, parent_account, account_name, root_type, report_type, is_group, lft, rgt
from `tabAccount` where company=%s order by lft""",
filters.company,
as_dict=True,
)
if not accounts:
return None
company_currency = filters.presentation_currency or erpnext.get_company_currency(filters.company)
ignore_is_opening = frappe.get_single_value("Accounts Settings", "ignore_is_opening_check_for_reporting")
accounts, accounts_by_name, parent_children_map = filter_accounts(accounts)
gl_entries_by_account = get_period_gl_entries_duckdb(conn, filters, ignore_is_opening)
opening_balances = get_opening_balances_duckdb(conn, filters, ignore_is_opening)
calculate_values(
accounts,
gl_entries_by_account,
opening_balances,
filters.get("show_net_values"),
ignore_is_opening=ignore_is_opening,
)
accumulate_values_into_parents(accounts, accounts_by_name)
data = prepare_data(accounts, filters, parent_children_map, company_currency)
return filter_out_zero_value_rows(
data, parent_children_map, show_zero_values=filters.get("show_zero_values")
)
def _extra_gl_conditions(filters):
"""Returns (conditions, params) for optional shared GL Entry filters."""
conditions, params = [], []
if filters.get("cost_center"):
cc = get_cost_centers_with_children(filters.get("cost_center"))
conditions.append(f"cost_center IN ({', '.join(['?'] * len(cc))})")
params.extend(cc)
if filters.get("project"):
proj = filters.project if isinstance(filters.project, list) else [filters.project]
conditions.append(f"project IN ({', '.join(['?'] * len(proj))})")
params.extend(proj)
if frappe.db.count("Finance Book"):
company_fb = frappe.get_cached_value("Company", filters.company, "default_finance_book")
if filters.get("include_default_book_entries"):
if filters.get("finance_book") and company_fb and cstr(filters.finance_book) != cstr(company_fb):
frappe.throw(
_("To use a different finance book, please uncheck 'Include Default FB Entries'")
)
fb_list = [cstr(filters.get("finance_book")), cstr(company_fb), ""]
else:
fb_list = [cstr(filters.get("finance_book")), ""]
conditions.append(f"(finance_book IN ({', '.join(['?'] * len(fb_list))}) OR finance_book IS NULL)")
params.extend(fb_list)
for dim in get_accounting_dimensions(as_list=False):
if filters.get(dim.fieldname):
if frappe.get_cached_value("DocType", dim.document_type, "is_tree"):
filters[dim.fieldname] = get_dimension_with_children(
dim.document_type, filters.get(dim.fieldname)
)
vals = (
filters[dim.fieldname]
if isinstance(filters[dim.fieldname], list)
else [filters[dim.fieldname]]
)
conditions.append(f"{dim.fieldname} IN ({', '.join(['?'] * len(vals))})")
params.extend(vals)
return conditions, params
def _fetch_gl_rows_duckdb(conn, conditions, params):
cols = [
"account",
"debit",
"credit",
"debit_in_account_currency",
"credit_in_account_currency",
"account_currency",
]
sql = f"""SELECT account, SUM(debit), SUM(credit),
SUM(debit_in_account_currency), SUM(credit_in_account_currency), account_currency
FROM "tabGL Entry" WHERE {" AND ".join(conditions)}
GROUP BY account, account_currency"""
return [frappe._dict(zip(cols, row, strict=False)) for row in conn.execute(sql, params).fetchall()]
def get_period_gl_entries_duckdb(conn, filters, ignore_is_opening):
conditions = ["company = ?", "is_cancelled = 0", "posting_date >= ?", "posting_date <= ?"]
params = [filters.company, filters.from_date, filters.to_date]
if not ignore_is_opening:
conditions.append("is_opening = 'No'")
if not flt(filters.get("with_period_closing_entry_for_current_period")):
conditions.append("voucher_type != 'Period Closing Voucher'")
extra_cond, extra_params = _extra_gl_conditions(filters)
conditions.extend(extra_cond)
params.extend(extra_params)
entries = _fetch_gl_rows_duckdb(conn, conditions, params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(entries, get_currency(filters))
gl_entries_by_account = {}
for entry in entries:
gl_entries_by_account.setdefault(entry.account, []).append(entry)
return gl_entries_by_account
def get_opening_balances_duckdb(conn, filters, ignore_is_opening):
bs = _get_rootwise_opening_duckdb(conn, filters, "Balance Sheet", ignore_is_opening)
pl = _get_rootwise_opening_duckdb(conn, filters, "Profit and Loss", ignore_is_opening)
bs.update(pl)
return bs
def _get_rootwise_opening_duckdb(conn, filters, report_type, ignore_is_opening):
accounting_dimensions = get_accounting_dimensions(as_list=False)
ignore_closing_balances = frappe.get_single_value("Accounts Settings", "ignore_account_closing_balance")
last_pcv = ""
if not ignore_closing_balances:
last_pcv = frappe.db.get_all(
"Period Closing Voucher",
filters={"docstatus": 1, "company": filters.company, "period_end_date": ("<", filters.from_date)},
fields=["period_end_date", "name"],
order_by="period_end_date desc",
limit=1,
)
if last_pcv:
# Account Closing Balance fetched via frappe (not GL Entry)
gle = get_opening_balance(
"Account Closing Balance",
filters,
report_type,
accounting_dimensions,
period_closing_voucher=last_pcv[0].name,
ignore_is_opening=ignore_is_opening,
)
if getdate(last_pcv[0].period_end_date) < getdate(add_days(filters.from_date, -1)):
start_date = add_days(last_pcv[0].period_end_date, 1)
gle += _get_gl_entry_opening_duckdb(
conn, filters, report_type, ignore_is_opening, start_date=start_date
)
else:
gle = _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening)
opening = frappe._dict()
for d in gle:
opening.setdefault(d.account, {"account": d.account, "opening_debit": 0.0, "opening_credit": 0.0})
opening[d.account]["opening_debit"] += flt(d.debit)
opening[d.account]["opening_credit"] += flt(d.credit)
return opening
def _get_gl_entry_opening_duckdb(conn, filters, report_type, ignore_is_opening, start_date=None):
accounts = frappe.db.get_all("Account", filters={"report_type": report_type}, pluck="name")
if not accounts:
return []
conditions = ["company = ?", f"account IN ({', '.join(['?'] * len(accounts))})", "is_cancelled = 0"]
params = [filters.company, *accounts]
if start_date:
conditions.append("posting_date >= ? AND posting_date < ?")
params.extend([start_date, filters.from_date])
if not ignore_is_opening:
conditions.append("is_opening = 'No'")
elif not ignore_is_opening:
conditions.append("(posting_date < ? OR is_opening = 'Yes')")
params.append(filters.from_date)
else:
conditions.append("posting_date < ?")
params.append(filters.from_date)
if not filters.get("show_unclosed_fy_pl_balances") and report_type == "Profit and Loss":
conditions.append("posting_date >= ?")
params.append(filters.year_start_date)
if not flt(filters.get("with_period_closing_entry_for_opening")):
conditions.append("voucher_type != 'Period Closing Voucher'")
extra_cond, extra_params = _extra_gl_conditions(filters)
conditions.extend(extra_cond)
params.extend(extra_params)
gle = _fetch_gl_rows_duckdb(conn, conditions, params)
if filters.get("presentation_currency"):
convert_to_presentation_currency(gle, get_currency(filters))
return gle

View File

@@ -1,5 +1,4 @@
import frappe
from frappe import _
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import flt, formatdate, get_datetime_str, get_table_name
@@ -17,19 +16,6 @@ from erpnext.setup.utils import get_exchange_rate
__exchange_rates = {}
def validate_mandatory_date_range(filters, from_field="from_date", to_field="to_date"):
from_date = filters.get(from_field)
to_date = filters.get(to_field)
if not from_date or not to_date:
frappe.throw(
_("{0} and {1} are mandatory").format(frappe.bold(_("From Date")), frappe.bold(_("To Date")))
)
if from_date > to_date:
frappe.throw(_("From Date must be before To Date"))
def get_currency(filters):
"""
Returns a dictionary containing currency information. The keys of the dict are
@@ -160,6 +146,7 @@ def get_appropriate_company(filters):
return company
@frappe.whitelist()
def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False):
from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator

View File

@@ -74,15 +74,6 @@ frappe.ui.form.on("Asset Repair", {
};
};
}
if (frm.doc.asset) {
frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => {
frm.set_df_property(
"capitalize_repair_cost",
"read_only",
message && message.status === "Fully Depreciated"
);
});
}
},
repair_status: (frm) => {

View File

@@ -140,7 +140,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Asset",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
"options": "Asset",
"reqd": 1
},
@@ -251,7 +251,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-07-14 10:00:00.000000",
"modified": "2026-01-06 15:48:13.862505",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Repair",

View File

@@ -77,15 +77,12 @@ class AssetRepair(AccountsController):
)
def validate_asset(self):
if self.asset_doc.status in ("Sold", "Scrapped"):
if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"):
frappe.throw(
_("Asset {0} is in {1} status and cannot be repaired.").format(
get_link_to_form("Asset", self.asset), self.asset_doc.status
)
)
if self.asset_doc.get_status() == "Fully Depreciated":
self.capitalize_repair_cost = 0
self.increase_in_asset_life = 0
def validate_dates(self):
if self.completion_date and (getdate(self.failure_date) > getdate(self.completion_date)):

View File

@@ -30,7 +30,9 @@ class BulkTransactionLog(Document):
def load_from_db(self):
log_detail = qb.DocType("Bulk Transaction Log Detail")
has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name})
has_records = frappe.db.sql(
f"select exists (select * from `tabBulk Transaction Log Detail` where date = '{self.name}');"
)[0][0]
if not has_records:
raise frappe.DoesNotExistError

View File

@@ -171,12 +171,14 @@
},
{
"allow_on_submit": 1,
"default": "{supplier_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1
"print_hide": 1,
"reqd": 1
},
{
"fieldname": "naming_series",
@@ -1307,7 +1309,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-07-28 12:20:11.284370",
"modified": "2025-07-31 17:19:40.816883",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -162,7 +162,7 @@ class PurchaseOrder(BuyingController):
taxes_and_charges_deducted: DF.Currency
tc_name: DF.Link | None
terms: DF.TextEditor | None
title: DF.Data | None
title: DF.Data
to_date: DF.Date | None
total: DF.Currency
total_net_weight: DF.Float

View File

@@ -16,10 +16,7 @@ from erpnext.accounts.party import (
validate_party_accounts,
validate_party_currency_before_merging,
)
from erpnext.controllers.website_list_for_contact import (
add_role_for_portal_user,
link_portal_users_to_contacts,
)
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
from erpnext.utilities.transaction_base import TransactionBase
@@ -106,7 +103,6 @@ class Supplier(TransactionBase):
def on_update(self):
self.create_primary_contact()
self.create_primary_address()
link_portal_users_to_contacts(self)
def add_role_for_user(self):
for portal_user in self.portal_users:

View File

@@ -5,7 +5,6 @@ def get_data():
return {
"fieldname": "supplier",
"non_standard_fieldnames": {"Payment Entry": "party", "Bank Account": "party"},
"dynamic_links": {"party": ["Supplier", "party_type"]},
"transactions": [
{"label": _("Procurement"), "items": ["Request for Quotation", "Supplier Quotation"]},
{"label": _("Orders"), "items": ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]},

View File

@@ -204,28 +204,7 @@ class TestSupplierPortal(FrappeTestCase):
supplier.append("portal_users", {"user": user})
supplier.save()
with self.set_user(user):
_, suppliers = get_customers_suppliers("Purchase Order", user)
frappe.set_user(user)
_, suppliers = get_customers_suppliers("Purchase Order", user)
self.assertIn(supplier.name, suppliers)
def test_portal_user_contact_link(self):
user_email = frappe.generate_hash() + "@example.com"
user = frappe.new_doc("User")
user.email = user_email
user.first_name = "Test Portal Contact User"
user.send_welcome_email = False
user.insert(ignore_permissions=True)
contact = frappe.new_doc("Contact")
contact.first_name = "Test Portal Contact User"
contact.add_email(user_email, is_primary=1)
contact.links = []
contact.insert(ignore_permissions=True)
supplier = create_supplier()
supplier.append("portal_users", {"user": user.name})
supplier.save()
contact.reload()
self.assertTrue(contact.has_link("Supplier", supplier.name))

View File

@@ -30,15 +30,11 @@
"stock_qty",
"sec_break_price_list",
"price_list_rate",
"base_price_list_rate",
"discount_and_margin_section",
"margin_type",
"margin_rate_or_amount",
"rate_with_margin",
"col_break_6",
"discount_percentage",
"discount_amount",
"distributed_discount_amount",
"col_break_price_list",
"base_price_list_rate",
"sec_break1",
"rate",
"amount",
@@ -307,7 +303,6 @@
"fieldname": "net_rate",
"fieldtype": "Currency",
"label": "Net Rate",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -536,6 +531,10 @@
"fieldname": "sec_break_price_list",
"fieldtype": "Section Break"
},
{
"fieldname": "col_break_price_list",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "ad_sec_break",
@@ -573,48 +572,13 @@
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency"
},
{
"depends_on": "price_list_rate",
"fieldname": "margin_type",
"fieldtype": "Select",
"label": "Margin Type",
"options": "\nPercentage\nAmount",
"print_hide": 1
},
{
"depends_on": "eval:doc.margin_type && doc.price_list_rate",
"fieldname": "margin_rate_or_amount",
"fieldtype": "Float",
"label": "Margin Rate or Amount",
"print_hide": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount",
"fieldname": "discount_and_margin_section",
"fieldtype": "Section Break",
"label": "Discount and Margin"
},
{
"depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount",
"fieldname": "rate_with_margin",
"fieldtype": "Currency",
"label": "Rate With Margin",
"options": "currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "col_break_6",
"fieldtype": "Column Break"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-15 10:33:24.855979",
"modified": "2024-06-02 06:22:18.864822",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",
@@ -625,4 +589,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -38,8 +38,6 @@ class SupplierQuotationItem(Document):
lead_time_days: DF.Int
manufacturer: DF.Link | None
manufacturer_part_no: DF.Data | None
margin_rate_or_amount: DF.Float
margin_type: DF.Literal["", "Percentage", "Amount"]
material_request: DF.Link | None
material_request_item: DF.Data | None
net_amount: DF.Currency
@@ -54,7 +52,6 @@ class SupplierQuotationItem(Document):
project: DF.Link | None
qty: DF.Float
rate: DF.Currency
rate_with_margin: DF.Currency
request_for_quotation: DF.Link | None
request_for_quotation_item: DF.Data | None
sales_order: DF.Link | None

View File

@@ -14,6 +14,7 @@ def execute(filters=None):
conditions = get_columns(filters, "Purchase Order")
data = get_data(filters, conditions)
chart_data = get_chart_data(data, conditions, filters)
return conditions["columns"], data, None, chart_data
@@ -38,15 +39,9 @@ def get_chart_data(data, conditions, filters):
labels = [column.split(":")[0] for column in columns]
datapoints = [0] * len(labels)
group_by_col_idx = None
if filters.get("group_by"):
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
for row in data:
# Skip the final grand-total row
if row[0] == f"'{_('Total')}'":
continue
if group_by_col_idx is not None and row[group_by_col_idx] == "":
# If group by filter, don't add first row of group (it's already summed)
if not row[start]:
continue
# Remove None values and compute only periodic data
row = [x if x else 0 for x in row[start:-2]]
@@ -65,6 +60,4 @@ def get_chart_data(data, conditions, filters):
"type": "line",
"lineOptions": {"regionFill": 1},
"fieldtype": "Currency",
"options": "currency",
"currency": conditions.get("company_currency"),
}

View File

@@ -141,26 +141,6 @@ class AccountsController(TransactionBase):
if self.doctype in relevant_docs:
self.set_payment_schedule()
def before_insert(self):
self.clear_clearance_date_on_amend()
def clear_clearance_date_on_amend(self):
"""Drop the bank reconciliation clearance date copied over while amending.
The framework copies `no_copy` fields when amending, so a reconciled
voucher would carry a stale clearance date into its amendment even though
the linked bank transaction gets unreconciled on cancellation.
"""
if not self.get("amended_from"):
return
if self.meta.has_field("clearance_date"):
self.clearance_date = None
for payment in self.get("payments") or []:
if payment.meta.has_field("clearance_date"):
payment.clearance_date = None
def remove_bundle_for_non_stock_invoices(self):
has_sabb = False
if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock:

Some files were not shown because too many files have changed in this diff Show More