mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-17 12:18:49 +00:00
Compare commits
2 Commits
version-16
...
v16.27.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d5c7605b8 | ||
|
|
f94eee3197 |
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.26.2"
|
||||
__version__ = "16.27.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
"account_type": "Cash"
|
||||
},
|
||||
"Petty Cash Fund": {
|
||||
"account_number": "1110",
|
||||
"account_number": "1200",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Cash",
|
||||
"Petty Cash Fund": {
|
||||
"account_number": "1111",
|
||||
"account_number": "1201",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Cash"
|
||||
@@ -35,16 +35,10 @@
|
||||
}
|
||||
},
|
||||
"Bank Accounts": {
|
||||
"account_number": "1200",
|
||||
"account_number": "1102",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Bank",
|
||||
"Cash in Bank - Checking Account": {
|
||||
"account_number": "1201",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Bank"
|
||||
}
|
||||
"account_type": "Bank"
|
||||
},
|
||||
"Advances to Officers & Employees": {
|
||||
"account_number": "1290",
|
||||
@@ -110,20 +104,25 @@
|
||||
"account_number": "1511",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
},
|
||||
"Factory Overhead Variance": {
|
||||
"account_number": "1512",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Finished Goods": {
|
||||
"account_number": "1540",
|
||||
"account_number": "1520",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"Finished Goods Inventory": {
|
||||
"account_number": "1541",
|
||||
"account_number": "1531",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Stock"
|
||||
},
|
||||
"Inventory in Transit": {
|
||||
"account_number": "1542",
|
||||
"account_number": "1532",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Stock Adjustment"
|
||||
@@ -269,7 +268,7 @@
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Intangible Assets": {
|
||||
"System Development": {
|
||||
"account_number": "1940",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
@@ -278,17 +277,6 @@
|
||||
"is_group": 0,
|
||||
"root_type": "Asset"
|
||||
}
|
||||
},
|
||||
"Accumulated Amortization - Intangible Assets": {
|
||||
"account_number": "1950",
|
||||
"is_group": 1,
|
||||
"root_type": "Asset",
|
||||
"Accum Amortization - System Development": {
|
||||
"account_number": "1951",
|
||||
"is_group": 0,
|
||||
"root_type": "Asset",
|
||||
"account_type": "Accumulated Depreciation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -574,28 +562,6 @@
|
||||
"is_group": 0,
|
||||
"root_type": "Income"
|
||||
}
|
||||
},
|
||||
"Exchange Gain": {
|
||||
"account_number": "6030",
|
||||
"is_group": 1,
|
||||
"root_type": "Income",
|
||||
"Exchange Gain - Detail": {
|
||||
"account_number": "6031",
|
||||
"is_group": 0,
|
||||
"root_type": "Income",
|
||||
"account_type": "Indirect Income"
|
||||
}
|
||||
},
|
||||
"Gain on Asset Disposal": {
|
||||
"account_number": "6040",
|
||||
"is_group": 1,
|
||||
"root_type": "Income",
|
||||
"Gain on Asset Disposal - Detail": {
|
||||
"account_number": "6041",
|
||||
"is_group": 0,
|
||||
"root_type": "Income",
|
||||
"account_type": "Indirect Income"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -608,7 +574,7 @@
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Cost of Goods Sold": {
|
||||
"account_number": "5002",
|
||||
"account_number": "5010",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Cost of Goods Sold"
|
||||
@@ -861,61 +827,20 @@
|
||||
"root_type": "Expense"
|
||||
}
|
||||
},
|
||||
"Other Expenses": {
|
||||
"account_number": "5200",
|
||||
"is_group": 1,
|
||||
"root_type": "Expense",
|
||||
"Bank Charges": {
|
||||
"account_number": "5201",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Interest Expenses Bank": {
|
||||
"account_number": "5202",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Write Off": {
|
||||
"account_number": "5203",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Exchange Loss": {
|
||||
"account_number": "5204",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
},
|
||||
"Loss on Asset Disposal": {
|
||||
"account_number": "5205",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Indirect Expense"
|
||||
}
|
||||
},
|
||||
"Provision For Income Tax": {
|
||||
"account_number": "5300",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Tax"
|
||||
},
|
||||
"Stock Adjustment": {
|
||||
"account_number": "5400",
|
||||
"account_number": "5200",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Stock Adjustment"
|
||||
},
|
||||
"Round Off": {
|
||||
"account_number": "5500",
|
||||
"account_number": "5300",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Round Off"
|
||||
},
|
||||
"Expenses Included In Valuation": {
|
||||
"account_number": "5600",
|
||||
"account_number": "5400",
|
||||
"is_group": 0,
|
||||
"root_type": "Expense",
|
||||
"account_type": "Expenses Included In Valuation"
|
||||
|
||||
@@ -218,7 +218,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()
|
||||
|
||||
@@ -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", "");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -163,46 +163,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 (
|
||||
@@ -281,3 +241,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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,200 +1,10 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
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(ERPNextTestSuite):
|
||||
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)
|
||||
pass
|
||||
|
||||
@@ -417,12 +417,11 @@ class JournalEntry(AccountsController):
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book)
|
||||
precision = je_row.precision("debit")
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.posting_date
|
||||
and not d.journal_entry
|
||||
and flt(d.depreciation_amount, precision) == flt(je_row.debit, precision)
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name)
|
||||
|
||||
|
||||
@@ -2814,7 +2814,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(
|
||||
|
||||
@@ -99,9 +99,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."))
|
||||
@@ -527,15 +527,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")
|
||||
|
||||
@@ -3156,6 +3156,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)
|
||||
@@ -3164,8 +3166,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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") if doc.get(\"company\") else None %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") if doc.get(\"company\") else None %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") if doc.get(\"company\") else None %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
|
||||
"creation": "2026-05-15 15:21:48.255627",
|
||||
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead",
|
||||
"modified": "2026-07-12 21:11:44.765083",
|
||||
"modified": "2026-06-24 17:49:52.350750",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"align": "Left",
|
||||
"content": "<table class=\"letterhead-container\" style=\"width:100%\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") if doc.get(\"company\") else None %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" style=\"width:200px\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t{% if doc.company %}<div class=\"company-name\">{{ doc.company }}</div>{% endif %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% if doc.company %}{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %}{% set website = company_details.website %}{% set email =\n\t\t\t\t\tcompany_details.email %}{% set phone_no = company_details.phone_no %}{% else %}{% set website = None %}{% set email = None %}{% set phone_no = None %}{% endif %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
|
||||
"creation": "2026-05-15 15:21:48.373815",
|
||||
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
|
||||
"disabled": 0,
|
||||
@@ -16,7 +16,7 @@
|
||||
"is_default": 0,
|
||||
"letter_head_for": "DocType",
|
||||
"letter_head_name": "Company Letterhead - Grey",
|
||||
"modified": "2026-07-12 22:03:24.525672",
|
||||
"modified": "2026-06-24 18:23:05.120521",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Company Letterhead - Grey",
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
"columns": [],
|
||||
"creation": "2013-04-22 16:16:03",
|
||||
"default_print_format": "Accounts Payable Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:37:41.185347",
|
||||
"modified": "2026-05-22 14:35:14.716933",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
@@ -40,6 +33,5 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
"columns": [],
|
||||
"creation": "2013-04-16 11:31:13",
|
||||
"default_print_format": "Accounts Receivable Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 5,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:37:44.167999",
|
||||
"modified": "2026-05-22 14:34:57.666402",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
@@ -34,6 +27,5 @@
|
||||
"role": "Accounts User"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-14 05:24:20.385279",
|
||||
"default_print_format": "Balance Sheet Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-06-22 13:38:25.236839",
|
||||
"modified": "2026-05-22 14:35:28.187799",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Balance Sheet",
|
||||
@@ -37,6 +30,5 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -4,27 +4,18 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import add_days, cint, flt
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
|
||||
FinancialReportEngine,
|
||||
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
|
||||
)
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -275,196 +266,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)
|
||||
|
||||
@@ -428,11 +428,6 @@ def build_comparison_chart_data(filters, columns, data):
|
||||
if not fieldname:
|
||||
continue
|
||||
|
||||
# skip the dimension column ("budget_against"), it only matches the
|
||||
# "budget_" prefix by coincidence and would shift the actual values by one
|
||||
if fieldname == "budget_against":
|
||||
continue
|
||||
|
||||
if fieldname.startswith("budget_"):
|
||||
budget_fields.append(fieldname)
|
||||
elif fieldname.startswith("actual_"):
|
||||
@@ -444,7 +439,7 @@ def build_comparison_chart_data(filters, columns, data):
|
||||
labels = [
|
||||
col["label"].replace("Budget", "").strip()
|
||||
for col in columns
|
||||
if col.get("fieldname", "").startswith("budget_") and col.get("fieldname") != "budget_against"
|
||||
if col.get("fieldname", "").startswith("budget_")
|
||||
]
|
||||
|
||||
budget_values = [0] * len(budget_fields)
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
"columns": [],
|
||||
"creation": "2013-12-06 13:22:23",
|
||||
"default_print_format": "General Ledger Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:36:06.682661",
|
||||
"modified": "2026-05-22 14:34:35.246000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
@@ -37,6 +30,5 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -818,288 +818,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
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-18 11:43:33.173207",
|
||||
"default_print_format": "P&L Statement Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 13:36:14.934965",
|
||||
"modified": "2026-05-22 14:36:04.544347",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
@@ -37,6 +30,5 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -11,20 +11,12 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_engine
|
||||
get_xlsx_styles, #! DO NOT REMOVE - hook for styling
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -205,125 +197,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)
|
||||
|
||||
@@ -4,20 +4,13 @@
|
||||
"columns": [],
|
||||
"creation": "2014-07-22 11:41:23.743564",
|
||||
"default_print_format": "Trial Balance Standard",
|
||||
"disable_prepared_report_automation": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"generate_csv": 0,
|
||||
"idx": 4,
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2026-07-01 17:32:21.801141",
|
||||
"modified": "2026-05-22 14:35:44.889062",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
@@ -37,6 +30,5 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -571,215 +571,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
|
||||
|
||||
@@ -1408,11 +1408,13 @@ def get_account_balances(accounts, company, finance_book=None, include_default_f
|
||||
def get_account_balances_coa(company: str, include_default_fb_balances: bool = False):
|
||||
company_currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
|
||||
account_list = frappe.get_list(
|
||||
"Account",
|
||||
fields=["name", "parent_account", "account_currency"],
|
||||
filters={"company": company},
|
||||
order_by="lft",
|
||||
Account = DocType("Account")
|
||||
account_list = (
|
||||
frappe.qb.from_(Account)
|
||||
.select(Account.name, Account.parent_account, Account.account_currency)
|
||||
.where(Account.company == company)
|
||||
.orderby(Account.lft)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
account_balances_cc = {account.get("name"): 0 for account in account_list}
|
||||
@@ -1422,8 +1424,9 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
|
||||
GLEntry = DocType("GL Entry")
|
||||
precision = get_currency_precision()
|
||||
get_ledger_balances_query = (
|
||||
frappe.get_query(GLEntry, fields=[GLEntry.account], ignore_permissions=False)
|
||||
frappe.qb.from_(GLEntry)
|
||||
.select(
|
||||
GLEntry.account,
|
||||
(Sum(Round(GLEntry.debit, precision)) - Sum(Round(GLEntry.credit, precision))).as_("balance"),
|
||||
(
|
||||
Sum(Round(GLEntry.debit_in_account_currency, precision))
|
||||
@@ -1433,7 +1436,7 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
|
||||
.groupby(GLEntry.account)
|
||||
)
|
||||
|
||||
conditions = [GLEntry.company == company, GLEntry.is_cancelled == 0]
|
||||
condition_list = [GLEntry.company == company, GLEntry.is_cancelled == 0]
|
||||
|
||||
default_finance_book = None
|
||||
|
||||
@@ -1441,9 +1444,12 @@ def get_account_balances_coa(company: str, include_default_fb_balances: bool = F
|
||||
default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book")
|
||||
|
||||
if default_finance_book:
|
||||
conditions.append((GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull()))
|
||||
condition_list.append(
|
||||
(GLEntry.finance_book == default_finance_book) | (GLEntry.finance_book.isnull())
|
||||
)
|
||||
|
||||
get_ledger_balances_query = get_ledger_balances_query.where(Criterion.all(conditions))
|
||||
for condition in condition_list:
|
||||
get_ledger_balances_query = get_ledger_balances_query.where(condition)
|
||||
|
||||
ledger_balances = get_ledger_balances_query.run(as_dict=True)
|
||||
|
||||
|
||||
190
erpnext/assets/dashboard_fixtures.py
Normal file
190
erpnext/assets/dashboard_fixtures.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import get_date_str, nowdate
|
||||
|
||||
from erpnext.accounts.dashboard_fixtures import _get_fiscal_year
|
||||
from erpnext.buying.dashboard_fixtures import get_company_for_dashboards
|
||||
|
||||
|
||||
def get_data():
|
||||
fiscal_year = _get_fiscal_year(nowdate())
|
||||
|
||||
if not fiscal_year:
|
||||
return frappe._dict()
|
||||
|
||||
year_start_date = get_date_str(fiscal_year.get("year_start_date"))
|
||||
year_end_date = get_date_str(fiscal_year.get("year_end_date"))
|
||||
|
||||
return frappe._dict(
|
||||
{
|
||||
"dashboards": get_dashboards(),
|
||||
"charts": get_charts(fiscal_year, year_start_date, year_end_date),
|
||||
"number_cards": get_number_cards(fiscal_year, year_start_date, year_end_date),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_dashboards():
|
||||
return [
|
||||
{
|
||||
"name": "Asset",
|
||||
"dashboard_name": "Asset",
|
||||
"charts": [
|
||||
{"chart": "Asset Value Analytics", "width": "Full"},
|
||||
{"chart": "Category-wise Asset Value", "width": "Half"},
|
||||
{"chart": "Location-wise Asset Value", "width": "Half"},
|
||||
],
|
||||
"cards": [
|
||||
{"card": "Total Assets"},
|
||||
{"card": "New Assets (This Year)"},
|
||||
{"card": "Asset Value"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def get_charts(fiscal_year, year_start_date, year_end_date):
|
||||
company = get_company_for_dashboards()
|
||||
return [
|
||||
{
|
||||
"name": "Asset Value Analytics",
|
||||
"chart_name": _("Asset Value Analytics"),
|
||||
"chart_type": "Report",
|
||||
"report_name": "Fixed Asset Register",
|
||||
"is_custom": 1,
|
||||
"group_by_type": "Count",
|
||||
"number_of_groups": 0,
|
||||
"is_public": 0,
|
||||
"timespan": "Last Year",
|
||||
"time_interval": "Yearly",
|
||||
"timeseries": 0,
|
||||
"filters_json": json.dumps(
|
||||
{
|
||||
"company": company,
|
||||
"status": "In Location",
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"from_fiscal_year": fiscal_year.get("name"),
|
||||
"to_fiscal_year": fiscal_year.get("name"),
|
||||
"period_start_date": year_start_date,
|
||||
"period_end_date": year_end_date,
|
||||
"date_based_on": "Purchase Date",
|
||||
"group_by": "--Select a group--",
|
||||
}
|
||||
),
|
||||
"type": "Bar",
|
||||
"custom_options": json.dumps(
|
||||
{
|
||||
"type": "bar",
|
||||
"barOptions": {"stacked": 1},
|
||||
"axisOptions": {"shortenYAxisNumbers": 1},
|
||||
"tooltipOptions": {},
|
||||
}
|
||||
),
|
||||
"doctype": "Dashboard Chart",
|
||||
"y_axis": [],
|
||||
},
|
||||
{
|
||||
"name": "Category-wise Asset Value",
|
||||
"chart_name": _("Category-wise Asset Value"),
|
||||
"chart_type": "Report",
|
||||
"report_name": "Fixed Asset Register",
|
||||
"x_field": "asset_category",
|
||||
"timeseries": 0,
|
||||
"filters_json": json.dumps(
|
||||
{
|
||||
"company": company,
|
||||
"status": "In Location",
|
||||
"group_by": "Asset Category",
|
||||
"asset_type": ["!=", "Existing Asset"],
|
||||
}
|
||||
),
|
||||
"type": "Donut",
|
||||
"doctype": "Dashboard Chart",
|
||||
"y_axis": [
|
||||
{
|
||||
"parent": "Category-wise Asset Value",
|
||||
"parentfield": "y_axis",
|
||||
"parenttype": "Dashboard Chart",
|
||||
"y_field": "asset_value",
|
||||
"doctype": "Dashboard Chart Field",
|
||||
}
|
||||
],
|
||||
"custom_options": json.dumps(
|
||||
{"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}}
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Location-wise Asset Value",
|
||||
"chart_name": "Location-wise Asset Value",
|
||||
"chart_type": "Report",
|
||||
"report_name": "Fixed Asset Register",
|
||||
"x_field": "location",
|
||||
"timeseries": 0,
|
||||
"filters_json": json.dumps(
|
||||
{
|
||||
"company": company,
|
||||
"status": "In Location",
|
||||
"group_by": "Location",
|
||||
"asset_type": ["!=", "Existing Asset"],
|
||||
}
|
||||
),
|
||||
"type": "Donut",
|
||||
"doctype": "Dashboard Chart",
|
||||
"y_axis": [
|
||||
{
|
||||
"parent": "Location-wise Asset Value",
|
||||
"parentfield": "y_axis",
|
||||
"parenttype": "Dashboard Chart",
|
||||
"y_field": "asset_value",
|
||||
"doctype": "Dashboard Chart Field",
|
||||
}
|
||||
],
|
||||
"custom_options": json.dumps(
|
||||
{"type": "donut", "height": 300, "axisOptions": {"shortenYAxisNumbers": 1}}
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_number_cards(fiscal_year, year_start_date, year_end_date):
|
||||
return [
|
||||
{
|
||||
"name": "Total Assets",
|
||||
"label": _("Total Assets"),
|
||||
"function": "Count",
|
||||
"document_type": "Asset",
|
||||
"is_public": 1,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Monthly",
|
||||
"filters_json": "[]",
|
||||
"doctype": "Number Card",
|
||||
},
|
||||
{
|
||||
"name": "New Assets (This Year)",
|
||||
"label": _("New Assets (This Year)"),
|
||||
"function": "Count",
|
||||
"document_type": "Asset",
|
||||
"is_public": 1,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Monthly",
|
||||
"filters_json": json.dumps([["Asset", "creation", "between", [year_start_date, year_end_date]]]),
|
||||
"doctype": "Number Card",
|
||||
},
|
||||
{
|
||||
"name": "Asset Value",
|
||||
"label": _("Asset Value"),
|
||||
"function": "Sum",
|
||||
"aggregate_function_based_on": "value_after_depreciation",
|
||||
"document_type": "Asset",
|
||||
"is_public": 1,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Monthly",
|
||||
"filters_json": "[]",
|
||||
"doctype": "Number Card",
|
||||
},
|
||||
]
|
||||
@@ -147,15 +147,7 @@ frappe.ui.form.on("Asset", {
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
if (frm.doc.status === "Fully Depreciated") {
|
||||
frm.add_custom_button(
|
||||
__("Asset Repair"),
|
||||
function () {
|
||||
frm.trigger("create_asset_repair");
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Split Asset"),
|
||||
function () {
|
||||
|
||||
@@ -1391,47 +1391,6 @@ class TestDepreciationBasics(AssetSetup):
|
||||
self.assertFalse(depr_schedule[1].journal_entry)
|
||||
self.assertFalse(depr_schedule[2].journal_entry)
|
||||
|
||||
def test_depr_schedule_link_matches_at_currency_precision(self):
|
||||
"""A Depreciation Schedule row whose amount carries more decimals than the
|
||||
company currency (e.g. 25701.202 vs a JE debit of 25701.20) must still be
|
||||
matched and stamped with the Journal Entry. Comparing at exact float
|
||||
equality left the link NULL, so the scheduler treated the row as unposted
|
||||
and created a duplicate Journal Entry on every run. Regression test for
|
||||
JournalEntry.update_journal_entry_link_on_depr_schedule()."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry import journal_entry as journal_entry_module
|
||||
|
||||
posting_date = getdate("2021-06-01")
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.name = "JE-DEPR-TEST"
|
||||
je.finance_book = None
|
||||
je.posting_date = posting_date
|
||||
|
||||
# JE debit is stored at company currency precision (2 dp)...
|
||||
je_row = MagicMock()
|
||||
je_row.debit = 25701.20
|
||||
je_row.precision.return_value = 2
|
||||
|
||||
# ...while the schedule row amount carries a third decimal.
|
||||
schedule_row = frappe._dict(
|
||||
name="DS-ROW-1",
|
||||
schedule_date=posting_date,
|
||||
journal_entry=None,
|
||||
depreciation_amount=25701.202,
|
||||
)
|
||||
asset = frappe._dict(name="ASSET-TEST")
|
||||
|
||||
with (
|
||||
patch.object(journal_entry_module, "get_depr_schedule", return_value=[schedule_row]),
|
||||
patch.object(frappe.db, "set_value") as mock_set_value,
|
||||
):
|
||||
je.update_journal_entry_link_on_depr_schedule(asset, je_row)
|
||||
|
||||
mock_set_value.assert_called_once_with(
|
||||
"Depreciation Schedule", "DS-ROW-1", "journal_entry", "JE-DEPR-TEST"
|
||||
)
|
||||
|
||||
def test_depr_entry_posting_when_depr_expense_account_is_an_expense_account(self):
|
||||
"""Tests if the Depreciation Expense Account gets debited and the Accumulated Depreciation Account gets credited when the former's an Expense Account."""
|
||||
|
||||
|
||||
@@ -84,15 +84,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"
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
show_general_ledger: function (frm) {
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Asset",
|
||||
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]",
|
||||
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\"]]]",
|
||||
"options": "Asset",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -275,7 +275,7 @@
|
||||
"link_fieldname": "asset_repair"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-20 15:43:54.943335",
|
||||
"modified": "2026-02-06 14:57:54.257572",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Repair",
|
||||
|
||||
@@ -70,15 +70,12 @@ class AssetRepair(AccountsController):
|
||||
self.check_repair_status()
|
||||
|
||||
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)):
|
||||
|
||||
@@ -270,7 +270,6 @@ class PurchaseOrder(BuyingController):
|
||||
"ref_dn_field": "material_request_item",
|
||||
"compare_fields": mri_compare_fields,
|
||||
"is_child_table": True,
|
||||
"allow_duplicate_prev_row_id": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -923,10 +923,8 @@
|
||||
"fieldname": "job_card",
|
||||
"fieldtype": "Link",
|
||||
"label": "Job Card",
|
||||
"no_copy": 1,
|
||||
"options": "Job Card",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
@@ -953,7 +951,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-15 10:30:04.600510",
|
||||
"modified": "2026-05-14 12:16:16.192936",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -112,7 +109,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:
|
||||
|
||||
@@ -203,24 +203,3 @@ class TestSupplierPortal(ERPNextTestSuite):
|
||||
_, 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))
|
||||
|
||||
@@ -307,7 +307,6 @@
|
||||
"fieldname": "net_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -614,7 +613,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-15 10:33:24.855979",
|
||||
"modified": "2025-06-17 12:05:52.441645",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -1347,63 +1347,66 @@ class StockController(AccountsController):
|
||||
if not batches:
|
||||
return
|
||||
|
||||
reference_fields = {
|
||||
"Sales Invoice": ["sales_order"],
|
||||
"Delivery Note": ["against_sales_order"],
|
||||
"Stock Entry": ["work_order", "subcontracting_inward_order"],
|
||||
field_mapper = {
|
||||
"Sales Invoice": [["Sales Order", "sales_order"]],
|
||||
"Delivery Note": [["Sales Order", "against_sales_order"]],
|
||||
"Stock Entry": [
|
||||
["Work Order", "work_order"],
|
||||
["Subcontracting Inward Order", "subcontracting_inward_order"],
|
||||
],
|
||||
}.get(self.doctype)
|
||||
|
||||
qty_field = {
|
||||
"Sales Invoice": "qty",
|
||||
"Delivery Note": "qty",
|
||||
"Stock Entry": "fg_completed_qty",
|
||||
}.get(self.doctype)
|
||||
|
||||
reserved_batches_data = self.get_reserved_batches(batches)
|
||||
items = self.items
|
||||
if self.doctype == "Stock Entry":
|
||||
items = [self]
|
||||
|
||||
own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)}
|
||||
for item in items:
|
||||
for field in field_mapper:
|
||||
if not item.get(field[1]):
|
||||
continue
|
||||
|
||||
outstanding_qty = defaultdict(float)
|
||||
reservations = defaultdict(list)
|
||||
for row in self.get_reserved_batches(batches):
|
||||
if row.voucher_no in own_vouchers:
|
||||
continue
|
||||
value = item.get(field[1])
|
||||
for row in reserved_batches_data:
|
||||
if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get(
|
||||
"item_code"
|
||||
):
|
||||
continue
|
||||
|
||||
key = (row.batch_no, row.warehouse)
|
||||
outstanding = flt(row.qty) - flt(row.delivered_qty)
|
||||
outstanding_qty[key] += outstanding
|
||||
if outstanding > 0:
|
||||
reservations[key].append(row)
|
||||
if row.voucher_no == value:
|
||||
continue
|
||||
|
||||
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
|
||||
if flt(reserved_qty, 6) <= 0:
|
||||
continue
|
||||
batch_qty = get_batch_qty(
|
||||
row.batch_no,
|
||||
row.warehouse,
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
|
||||
batch_qty = get_batch_qty(
|
||||
batch_no,
|
||||
warehouse,
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
if item.get(qty_field) < batch_qty:
|
||||
continue
|
||||
|
||||
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
|
||||
continue
|
||||
|
||||
vouchers = ", ".join(
|
||||
f"{frappe.bold(voucher_type)} {frappe.bold(voucher_no)}"
|
||||
for voucher_type, voucher_no in dict.fromkeys(
|
||||
(row.voucher_type, row.voucher_no) for row in reservations[(batch_no, warehouse)]
|
||||
)
|
||||
)
|
||||
frappe.throw(
|
||||
_(
|
||||
"The batch {0} is reserved for {1} in the warehouse {2} and the remaining quantity is not enough to cover the reservations. So, cannot proceed with the {3} {4}."
|
||||
).format(
|
||||
frappe.bold(batch_no),
|
||||
vouchers,
|
||||
frappe.bold(warehouse),
|
||||
frappe.bold(self.doctype),
|
||||
frappe.bold(self.name),
|
||||
),
|
||||
title=_("Reserved Batch Conflict"),
|
||||
)
|
||||
frappe.throw(
|
||||
_(
|
||||
"The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}."
|
||||
).format(
|
||||
frappe.bold(row.batch_no),
|
||||
frappe.bold(row.voucher_type),
|
||||
frappe.bold(row.voucher_no),
|
||||
frappe.bold(self.doctype),
|
||||
frappe.bold(self.name),
|
||||
frappe.bold(field[0]),
|
||||
frappe.bold(value),
|
||||
),
|
||||
title=_("Reserved Batch Conflict"),
|
||||
)
|
||||
|
||||
def get_reserved_batches(self, batches):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
@@ -1415,10 +1418,9 @@ class StockController(AccountsController):
|
||||
.on(doctype.name == child_doc.parent)
|
||||
.select(
|
||||
child_doc.batch_no,
|
||||
child_doc.qty,
|
||||
child_doc.delivered_qty,
|
||||
doctype.voucher_type,
|
||||
doctype.voucher_no,
|
||||
doctype.item_code,
|
||||
doctype.warehouse,
|
||||
)
|
||||
.where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches)))
|
||||
@@ -2055,7 +2057,7 @@ class StockController(AccountsController):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
||||
def show_accounting_ledger_preview(company, doctype, docname):
|
||||
filters = frappe._dict(company=company, include_dimensions=1)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
@@ -2069,7 +2071,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
||||
def show_stock_ledger_preview(company, doctype, docname):
|
||||
filters = frappe._dict(company=company)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
|
||||
@@ -7,8 +7,6 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.modules.utils import get_module_app
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Lower
|
||||
from frappe.utils import cint, flt, has_common
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
@@ -308,63 +306,3 @@ def add_role_for_portal_user(portal_user, role):
|
||||
|
||||
user_doc.add_roles(role)
|
||||
frappe.msgprint(_("Added {1} Role to User {0}.").format(frappe.bold(user_doc.name), role), alert=True)
|
||||
|
||||
|
||||
def link_portal_users_to_contacts(doc):
|
||||
"""When portal users are added to Supplier/Customer, link them to the Contact profile."""
|
||||
# a User's name is its (lowercased) email, so portal_users are already the emails
|
||||
portal_users = {p.user for p in doc.get("portal_users") or [] if p.user}
|
||||
if not portal_users:
|
||||
return
|
||||
|
||||
before = doc.get_doc_before_save()
|
||||
if before:
|
||||
previous_users = {p.user for p in before.get("portal_users") or [] if p.user}
|
||||
if portal_users == previous_users:
|
||||
return
|
||||
|
||||
portal_users = list(portal_users)
|
||||
|
||||
contact = frappe.qb.DocType("Contact")
|
||||
contact_email = frappe.qb.DocType("Contact Email")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(contact)
|
||||
.left_join(contact_email)
|
||||
.on(contact_email.parent == contact.name)
|
||||
.select(contact.name)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
conditions = [
|
||||
contact.user.isin(portal_users),
|
||||
Lower(contact.email_id).isin(portal_users),
|
||||
Lower(contact_email.email_id).isin(portal_users),
|
||||
]
|
||||
|
||||
query = query.where(Criterion.any(conditions))
|
||||
contacts = query.run(pluck=True)
|
||||
|
||||
if not contacts:
|
||||
return
|
||||
|
||||
dynamic_link = frappe.qb.DocType("Dynamic Link")
|
||||
existing_links = (
|
||||
frappe.qb.from_(dynamic_link)
|
||||
.select(dynamic_link.parent)
|
||||
.where(
|
||||
(dynamic_link.parenttype == "Contact")
|
||||
& (dynamic_link.parent.isin(contacts))
|
||||
& (dynamic_link.link_doctype == doc.doctype)
|
||||
& (dynamic_link.link_name == doc.name)
|
||||
)
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
contacts_to_link = [name for name in contacts if name not in existing_links]
|
||||
|
||||
for name in contacts_to_link:
|
||||
contact_doc = frappe.get_doc("Contact", name)
|
||||
if not contact_doc.has_link(doc.doctype, doc.name):
|
||||
contact_doc.append("links", {"link_doctype": doc.doctype, "link_name": doc.name})
|
||||
contact_doc.save(ignore_permissions=True)
|
||||
|
||||
@@ -492,7 +492,6 @@ scheduler_events = {
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
"erpnext.stock.doctype.stock_reposting_settings.stock_reposting_settings.repost_incorrect_valuation_entries",
|
||||
],
|
||||
"monthly_long": [
|
||||
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
|
||||
|
||||
2524
erpnext/locale/ar.po
2524
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
2073
erpnext/locale/bg.po
2073
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
4469
erpnext/locale/bs.po
4469
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
2546
erpnext/locale/cs.po
2546
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
2081
erpnext/locale/da.po
2081
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
2484
erpnext/locale/de.po
2484
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
21926
erpnext/locale/eo.po
21926
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
2564
erpnext/locale/es.po
2564
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
2366
erpnext/locale/fa.po
2366
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
2552
erpnext/locale/fr.po
2552
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
2091
erpnext/locale/hi.po
2091
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3487
erpnext/locale/hr.po
3487
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
2540
erpnext/locale/hu.po
2540
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
2419
erpnext/locale/id.po
2419
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
2535
erpnext/locale/it.po
2535
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
2208
erpnext/locale/ko.po
2208
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
2080
erpnext/locale/my.po
2080
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
2210
erpnext/locale/nb.po
2210
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
2480
erpnext/locale/nl.po
2480
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
2526
erpnext/locale/pl.po
2526
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
2539
erpnext/locale/pt.po
2539
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2423
erpnext/locale/ru.po
2423
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
2197
erpnext/locale/sl.po
2197
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
2290
erpnext/locale/sr.po
2290
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2364
erpnext/locale/sv.po
2364
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
2283
erpnext/locale/th.po
2283
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
2267
erpnext/locale/tr.po
2267
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
2334
erpnext/locale/uz.po
2334
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
2331
erpnext/locale/vi.po
2331
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
2313
erpnext/locale/zh.po
2313
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -457,49 +457,6 @@ class TestJobCard(ERPNextTestSuite):
|
||||
job_card.reload()
|
||||
self.assertEqual(job_card.transferred_qty, 0.0)
|
||||
|
||||
def test_work_order_transferred_qty_with_multiple_job_cards(self):
|
||||
create_bom_with_multiple_operations()
|
||||
work_order = make_wo_with_transfer_against_jc()
|
||||
self.generate_required_stock(work_order)
|
||||
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={"work_order": work_order.name},
|
||||
pluck="name",
|
||||
order_by="sequence_id",
|
||||
)
|
||||
completed_qty = (4, 3)
|
||||
|
||||
for job_card_name, qty in zip(job_cards, completed_qty, strict=True):
|
||||
job_card = frappe.get_doc("Job Card", job_card_name)
|
||||
job_card.for_quantity = qty
|
||||
job_card.save()
|
||||
|
||||
transfer_entry = make_stock_entry_from_jc(job_card.name)
|
||||
transfer_entry.fg_completed_qty = qty
|
||||
transfer_entry.get_items()
|
||||
transfer_entry.submit()
|
||||
|
||||
job_card.reload()
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": now(),
|
||||
"to_time": add_to_date(now(), hours=1),
|
||||
"completed_qty": qty,
|
||||
},
|
||||
)
|
||||
job_card.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty))
|
||||
|
||||
# Refreshing required items must not replace the Job Card roll-up with the sum
|
||||
# of FG quantities from Material Transfer Stock Entries (4 + 3).
|
||||
work_order.update_required_items()
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, min(completed_qty))
|
||||
|
||||
def test_job_card_material_transfer_correctness(self):
|
||||
"""
|
||||
1. Test if only current Job Card Items are pulled in a Stock Entry against a Job Card
|
||||
|
||||
@@ -445,6 +445,8 @@ frappe.ui.form.on("Production Plan", {
|
||||
frappe.throw(__("Select the Warehouse"));
|
||||
}
|
||||
|
||||
frm.set_value("consider_minimum_order_qty", 0);
|
||||
|
||||
if (!frm.doc.ignore_existing_ordered_qty) {
|
||||
frm.events.get_items_for_material_requests(frm);
|
||||
} else {
|
||||
|
||||
@@ -1823,13 +1823,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
if (ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses:
|
||||
new_mr_items = []
|
||||
for item in mr_items:
|
||||
get_materials_from_other_locations(
|
||||
item,
|
||||
warehouses,
|
||||
new_mr_items,
|
||||
company,
|
||||
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
|
||||
)
|
||||
get_materials_from_other_locations(item, warehouses, new_mr_items, company)
|
||||
|
||||
mr_items = new_mr_items
|
||||
|
||||
@@ -1851,9 +1845,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
return mr_items
|
||||
|
||||
|
||||
def get_materials_from_other_locations(
|
||||
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False
|
||||
):
|
||||
def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
|
||||
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
|
||||
|
||||
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
|
||||
@@ -1896,8 +1888,7 @@ def get_materials_from_other_locations(
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
if flt(required_qty, precision) > 0:
|
||||
if consider_minimum_order_qty:
|
||||
required_qty = max(required_qty, flt(item.get("min_order_qty")))
|
||||
required_qty = required_qty
|
||||
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
@@ -2030,18 +2030,6 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
for d in mr_items:
|
||||
self.assertEqual(d.get("quantity"), 1000.0)
|
||||
|
||||
source_warehouse = create_warehouse("MOQ Source Warehouse", company="_Test Company")
|
||||
make_stock_entry(item_code=rm_item, qty=7, rate=100, target=source_warehouse)
|
||||
|
||||
pln.ignore_existing_ordered_qty = 1
|
||||
mr_items = get_items_for_material_requests(
|
||||
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
|
||||
)
|
||||
self.assertEqual(len(mr_items), 2)
|
||||
items_by_type = {d.get("material_request_type"): d for d in mr_items}
|
||||
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
|
||||
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
|
||||
|
||||
def test_fg_item_quantity(self):
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
@@ -1806,10 +1806,6 @@ class WorkOrder(Document):
|
||||
|
||||
def recompute_material_transferred_for_manufacturing(self, transferred_items):
|
||||
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
|
||||
# Job Card transfers use the minimum completed quantity across operations.
|
||||
if self.operations and self.transfer_material_against == "Job Card":
|
||||
return
|
||||
|
||||
# When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
|
||||
# SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
|
||||
sum_fg_completed_qty = self.get_transferred_or_manufactured_qty(
|
||||
|
||||
@@ -490,4 +490,3 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
|
||||
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
|
||||
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
|
||||
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
|
||||
erpnext.patches.v16_0.access_control_for_project_users
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
Project = frappe.qb.DocType("Project")
|
||||
ProjectUser = frappe.qb.DocType("Project User")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Project)
|
||||
.join(ProjectUser)
|
||||
.on(Project.name == ProjectUser.parent)
|
||||
.select(Project.name, ProjectUser.user)
|
||||
)
|
||||
|
||||
proj_users = query.run(as_dict=1)
|
||||
|
||||
project_mapped_users = get_project_mapped_users(proj_users)
|
||||
|
||||
for d in proj_users:
|
||||
if d.user in project_mapped_users[d.name]:
|
||||
continue
|
||||
|
||||
frappe.share.add_docshare("Project", d.name, user=d.user)
|
||||
|
||||
|
||||
def get_project_mapped_users(proj_users):
|
||||
projects = set([d.name for d in proj_users])
|
||||
project_mapped_users = {}
|
||||
|
||||
for d in projects:
|
||||
project_mapped_users[d] = [d.user for d in frappe.share.get_users("Project", d)]
|
||||
|
||||
return project_mapped_users
|
||||
@@ -86,7 +86,7 @@
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "status",
|
||||
"oldfieldtype": "Select",
|
||||
"options": "Open\nOn hold\nCompleted\nCancelled",
|
||||
"options": "Open\nCompleted\nCancelled",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
@@ -210,15 +210,13 @@
|
||||
"fieldname": "users",
|
||||
"fieldtype": "Table",
|
||||
"label": "Users",
|
||||
"options": "Project User",
|
||||
"permlevel": 1
|
||||
"options": "Project User"
|
||||
},
|
||||
{
|
||||
"fieldname": "copied_from",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Copied From",
|
||||
"permlevel": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -484,25 +482,13 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"max_attachments": 4,
|
||||
"modified": "2026-07-14 14:32:11.328347",
|
||||
"modified": "2026-05-22 16:45:50.762759",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Project",
|
||||
"naming_rule": "By \"Naming Series\" field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"permlevel": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Projects Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
|
||||
@@ -61,7 +61,7 @@ class Project(Document):
|
||||
project_type: DF.Link | None
|
||||
sales_order: DF.Link | None
|
||||
second_email: DF.Time | None
|
||||
status: DF.Literal["Open", "On hold", "Completed", "Cancelled"]
|
||||
status: DF.Literal["Open", "Completed", "Cancelled"]
|
||||
subject: DF.Data | None
|
||||
to_time: DF.Time | None
|
||||
total_billable_amount: DF.Currency
|
||||
@@ -92,8 +92,7 @@ class Project(Document):
|
||||
|
||||
def validate(self):
|
||||
if not self.is_new():
|
||||
self.copy_from_template()
|
||||
self.control_access_for_project_users()
|
||||
self.copy_from_template() # nosemgrep
|
||||
self.send_welcome_email()
|
||||
self.update_costing()
|
||||
self.update_percent_complete()
|
||||
@@ -206,7 +205,6 @@ class Project(Document):
|
||||
self.copy_from_template() # nosemgrep
|
||||
if self.sales_order:
|
||||
frappe.db.set_value("Sales Order", self.sales_order, "project", self.name)
|
||||
self.control_access_for_project_users()
|
||||
|
||||
def on_trash(self):
|
||||
frappe.db.set_value("Sales Order", {"project": self.name}, "project", "")
|
||||
@@ -264,8 +262,8 @@ class Project(Document):
|
||||
pct_complete += row["progress"] * frappe.utils.safe_div(row["task_weight"], weight_sum)
|
||||
self.percent_complete = flt(flt(pct_complete), 2)
|
||||
|
||||
# don't update status if it is manually set to cancelled or on hold
|
||||
if self.status in ("Cancelled", "On hold"):
|
||||
# don't update status if it is cancelled
|
||||
if self.status == "Cancelled":
|
||||
return
|
||||
|
||||
self.status = "Completed" if self.percent_complete == 100 else "Open"
|
||||
@@ -379,34 +377,6 @@ class Project(Document):
|
||||
)
|
||||
user.welcome_email_sent = 1
|
||||
|
||||
def control_access_for_project_users(self):
|
||||
def revoke_access_for_project_users(removed_users):
|
||||
users = set([d.user for d in frappe.share.get_users(self.doctype, self.name)])
|
||||
for user in removed_users:
|
||||
if user not in users:
|
||||
continue
|
||||
|
||||
frappe.share.remove(self.doctype, self.name, user)
|
||||
|
||||
def grant_access_for_project_users(new_users):
|
||||
for user in new_users:
|
||||
frappe.share.add_docshare(self.doctype, self.name, user=user)
|
||||
|
||||
current_users = set([d.user for d in self.users])
|
||||
old_doc = self.get_doc_before_save()
|
||||
|
||||
if not old_doc:
|
||||
grant_access_for_project_users(current_users)
|
||||
return
|
||||
|
||||
previous_users = set([d.user for d in old_doc.users])
|
||||
|
||||
new_users = current_users - previous_users
|
||||
removed_users = previous_users - current_users
|
||||
|
||||
revoke_access_for_project_users(removed_users)
|
||||
grant_access_for_project_users(new_users)
|
||||
|
||||
|
||||
def get_timeline_data(doctype: str, name: str) -> dict[int, int]:
|
||||
"""Return timeline for attendance"""
|
||||
|
||||
@@ -4,8 +4,6 @@ frappe.listview_settings["Project"] = {
|
||||
get_indicator: function (doc) {
|
||||
if (doc.status == "Open" && doc.percent_complete) {
|
||||
return [__("{0}%", [cint(doc.percent_complete)]), "orange", "percent_complete,>,0|status,=,Open"];
|
||||
} else if (doc.status == "On hold") {
|
||||
return [__("On hold"), "blue", "status,=,On hold"];
|
||||
} else {
|
||||
return [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status];
|
||||
}
|
||||
|
||||
@@ -244,61 +244,6 @@ class TestProject(ERPNextTestSuite):
|
||||
project.save()
|
||||
self.assertEqual(project.status, "Completed")
|
||||
|
||||
def _create_portal_user(self, email):
|
||||
"""A user with no Project-related role, so read access can only come from
|
||||
control_access_for_project_users() sharing the doc with them."""
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": email,
|
||||
"first_name": "Portal",
|
||||
"send_welcome_email": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
return email
|
||||
|
||||
def test_new_project_grants_access_to_its_users(self):
|
||||
member = self._create_portal_user(f"new_proj_member_{frappe.generate_hash(length=6)}@example.com")
|
||||
|
||||
project = frappe.get_doc(
|
||||
doctype="Project",
|
||||
project_name=f"_Test New Project Access {frappe.generate_hash(length=6)}",
|
||||
status="Open",
|
||||
company="_Test Company",
|
||||
)
|
||||
project.append("users", {"user": member, "welcome_email_sent": 1})
|
||||
project.insert() # must not raise
|
||||
|
||||
self.assertTrue(project.has_permission(user=member))
|
||||
shared_with = [d.user for d in frappe.share.get_users("Project", project.name)]
|
||||
self.assertIn(member, shared_with)
|
||||
|
||||
def test_adding_and_removing_project_user_updates_access(self):
|
||||
stays = self._create_portal_user(f"stays_{frappe.generate_hash(length=6)}@example.com")
|
||||
leaves = self._create_portal_user(f"leaves_{frappe.generate_hash(length=6)}@example.com")
|
||||
|
||||
project = frappe.get_doc(
|
||||
doctype="Project",
|
||||
project_name=f"_Test Project User Membership {frappe.generate_hash(length=6)}",
|
||||
status="Open",
|
||||
company="_Test Company",
|
||||
)
|
||||
project.append("users", {"user": stays, "welcome_email_sent": 1})
|
||||
project.insert()
|
||||
self.assertTrue(project.has_permission(user=stays))
|
||||
|
||||
# adding a user on update (not insert) must also grant them access
|
||||
project.append("users", {"user": leaves, "welcome_email_sent": 1})
|
||||
project.save()
|
||||
self.assertTrue(project.has_permission(user=leaves))
|
||||
|
||||
# removing a user must revoke the share that was granted for membership
|
||||
project.users = [d for d in project.users if d.user != leaves]
|
||||
project.save()
|
||||
self.assertFalse(project.has_permission(user=leaves))
|
||||
self.assertTrue(project.has_permission(user=stays))
|
||||
|
||||
|
||||
def get_project(name, template):
|
||||
project = frappe.get_doc(
|
||||
|
||||
@@ -15,11 +15,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
this.warehouse_field = opts.warehouse_field || "warehouse";
|
||||
// field name on row which defines max quantity to be scanned e.g. picklist
|
||||
this.max_qty_field = opts.max_qty_field;
|
||||
// row fields that, if set, mean max_qty_field is a real demand qty (e.g. from a
|
||||
// linked Sales Order) that scanning must not exceed. Rows with none of these set
|
||||
// have no real demand qty, so max_qty_field is just an arbitrary default and
|
||||
// shouldn't cap further scans.
|
||||
this.demand_ref_fields = opts.demand_ref_fields || [];
|
||||
// scanner won't add a new row if this flag is set.
|
||||
this.dont_allow_new_row = opts.dont_allow_new_row;
|
||||
// scanner will ask user to type the quantity instead of incrementing by 1
|
||||
@@ -395,9 +390,6 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
}
|
||||
|
||||
async set_barcode_uom(row, uom) {
|
||||
// e.g. Pick List: picked_qty is always tracked in stock UOM, so an incidental
|
||||
// barcode uom must not overwrite the row's own uom.
|
||||
if (this.max_qty_field) return;
|
||||
if (uom && frappe.meta.has_field(row.doctype, this.uom_field)) {
|
||||
await frappe.model.set_value(row.doctype, row.name, this.uom_field, uom);
|
||||
}
|
||||
@@ -462,9 +454,8 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
const matching_row = (row) => {
|
||||
const item_match = row.item_code == item_code;
|
||||
const batch_match = !row[this.batch_no_field] || row[this.batch_no_field] == batch_no;
|
||||
const uom_match = !uom || this.max_qty_field || row[this.uom_field] == uom;
|
||||
const has_demand_qty = this.demand_ref_fields.some((fieldname) => row[fieldname]);
|
||||
const qty_in_limit = !has_demand_qty || flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
|
||||
const uom_match = !uom || row[this.uom_field] == uom;
|
||||
const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
|
||||
const item_scanned = row.has_item_scanned;
|
||||
|
||||
let warehouse_match = true;
|
||||
|
||||
@@ -284,17 +284,19 @@ erpnext.sales_common = {
|
||||
|
||||
set_actual_qty(doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order", "Quotation"];
|
||||
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order"];
|
||||
|
||||
if (row.item_code && row.warehouse && sales_doctypes.includes(doc.doctype)) {
|
||||
return this.frm.call({
|
||||
frappe.call({
|
||||
method: "erpnext.stock.get_item_details.get_bin_details",
|
||||
child: row,
|
||||
args: {
|
||||
item_code: row.item_code,
|
||||
warehouse: row.warehouse,
|
||||
company: doc.company,
|
||||
include_child_warehouses: true,
|
||||
},
|
||||
callback(r) {
|
||||
if (r.message) {
|
||||
frappe.model.set_value(cdt, cdn, "actual_qty", r.message.actual_qty);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,10 +24,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
|
||||
|
||||
|
||||
@@ -276,8 +273,6 @@ class Customer(TransactionBase):
|
||||
|
||||
self.update_customer_groups()
|
||||
|
||||
link_portal_users_to_contacts(self)
|
||||
|
||||
def add_role_for_user(self):
|
||||
for portal_user in self.portal_users:
|
||||
add_role_for_portal_user(portal_user, "Customer")
|
||||
|
||||
@@ -398,33 +398,6 @@ class TestCustomer(ERPNextTestSuite):
|
||||
self.assertEqual(middle, "Michael")
|
||||
self.assertEqual(last, "Doe")
|
||||
|
||||
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 Customer User"
|
||||
user.send_welcome_email = False
|
||||
user.insert(ignore_permissions=True)
|
||||
|
||||
contact = frappe.new_doc("Contact")
|
||||
contact.first_name = "Test Portal Customer User"
|
||||
contact.add_email(user_email, is_primary=1)
|
||||
contact.links = []
|
||||
contact.insert(ignore_permissions=True)
|
||||
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Customer",
|
||||
"customer_name": "Test Portal Contact Customer",
|
||||
"customer_type": "Individual",
|
||||
}
|
||||
)
|
||||
customer.append("portal_users", {"user": user.name})
|
||||
customer.insert()
|
||||
|
||||
contact.reload()
|
||||
self.assertTrue(contact.has_link("Customer", customer.name))
|
||||
|
||||
|
||||
def get_customer_dict(customer_name):
|
||||
return {
|
||||
|
||||
@@ -30,7 +30,7 @@ class TermsandConditions(Document):
|
||||
|
||||
def validate(self):
|
||||
if self.terms:
|
||||
validate_template(self.terms, restrict_globals=True)
|
||||
validate_template(self.terms)
|
||||
if not cint(self.buying) and not cint(self.selling) and not cint(self.hr) and not cint(self.disabled):
|
||||
throw(_("At least one of the Applicable Modules should be selected"))
|
||||
|
||||
@@ -40,10 +40,7 @@ def get_terms_and_conditions(template_name, doc):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
tnc = frappe.get_cached_doc("Terms and Conditions", template_name)
|
||||
tnc.check_permission()
|
||||
terms_and_conditions = frappe.get_doc("Terms and Conditions", template_name)
|
||||
|
||||
if not tnc.terms:
|
||||
return
|
||||
|
||||
return frappe.render_template(tnc.terms, doc, restrict_globals=1)
|
||||
if terms_and_conditions.terms:
|
||||
return frappe.render_template(terms_and_conditions.terms, doc)
|
||||
|
||||
@@ -288,8 +288,7 @@ frappe.ui.form.on("Pick List", {
|
||||
items_table_name: "locations",
|
||||
qty_field: "picked_qty",
|
||||
max_qty_field: "qty",
|
||||
demand_ref_fields: ["sales_order_item", "material_request_item", "product_bundle_item"],
|
||||
dont_allow_new_row: !frm.doc.pick_manually,
|
||||
dont_allow_new_row: true,
|
||||
prompt_qty: frm.doc.prompt_qty,
|
||||
serial_no_field: "not_supported", // doesn't make sense for picklist without a separate field.
|
||||
};
|
||||
|
||||
@@ -2584,24 +2584,22 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos):
|
||||
|
||||
def get_bundle_wise_serial_nos(data, kwargs):
|
||||
bundle_wise_serial_nos = defaultdict(list)
|
||||
bundles = list({d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle})
|
||||
bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle]
|
||||
if not bundles:
|
||||
return bundle_wise_serial_nos
|
||||
|
||||
sabe = frappe.qb.DocType("Serial and Batch Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sabe)
|
||||
.select(sabe.serial_no, sabe.parent)
|
||||
.where(sabe.parent.isin(bundles))
|
||||
.where(sabe.docstatus == 1)
|
||||
.where(sabe.serial_no.isnotnull())
|
||||
.where(sabe.serial_no != "")
|
||||
)
|
||||
filters = {"parent": ("in", bundles), "docstatus": 1, "serial_no": ("is", "set")}
|
||||
|
||||
if kwargs.get("check_serial_nos") and kwargs.get("serial_nos"):
|
||||
query = query.where(sabe.serial_no.isin(kwargs.get("serial_nos")))
|
||||
filters["serial_no"] = ("in", kwargs.get("serial_nos"))
|
||||
|
||||
for d in query.run(as_dict=True):
|
||||
bundle_data = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
fields=["serial_no", "parent"],
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
for d in bundle_data:
|
||||
if d.parent:
|
||||
bundle_wise_serial_nos[d.parent].append(d.serial_no)
|
||||
|
||||
|
||||
@@ -10,12 +10,8 @@ from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
add_serial_batch_ledgers,
|
||||
combine_datetime,
|
||||
get_available_batches_qty,
|
||||
get_qty_based_available_batches,
|
||||
get_type_of_transaction,
|
||||
make_batch_nos,
|
||||
make_serial_nos,
|
||||
parse_serial_nos,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -1471,133 +1467,3 @@ def make_serial_batch_bundle(kwargs):
|
||||
return sb.make_serial_and_batch_bundle()
|
||||
|
||||
return sb
|
||||
|
||||
|
||||
class TestSerialandBatchBundleLogic(ERPNextTestSuite):
|
||||
"""Pure helpers and in-memory document validations, covering branches the
|
||||
integration suite doesn't reach (no stock-ledger / serial / batch fixtures)."""
|
||||
|
||||
def test_parse_serial_nos_splits_and_trims(self):
|
||||
self.assertEqual(parse_serial_nos("SN1\nSN2"), ["SN1", "SN2"])
|
||||
self.assertEqual(parse_serial_nos("SN1, SN2 , SN3"), ["SN1", "SN2", "SN3"])
|
||||
# blanks are dropped and an existing list is returned unchanged
|
||||
self.assertEqual(parse_serial_nos("SN1,,\n , SN2"), ["SN1", "SN2"])
|
||||
self.assertEqual(parse_serial_nos(["SN1", "SN2"]), ["SN1", "SN2"])
|
||||
|
||||
def test_get_qty_based_available_batches_allocates_across_batches(self):
|
||||
batches = [
|
||||
frappe._dict(batch_no="B1", qty=10, warehouse="W"),
|
||||
frappe._dict(batch_no="B2", qty=5, warehouse="W"),
|
||||
]
|
||||
# 12 consumes B1 fully then 2 from B2
|
||||
result = get_qty_based_available_batches(batches, 12)
|
||||
self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 10), ("B2", 2)])
|
||||
# 8 is satisfied by B1 alone; B2 is not touched
|
||||
result = get_qty_based_available_batches(batches, 8)
|
||||
self.assertEqual([(b.batch_no, b.qty) for b in result], [("B1", 8)])
|
||||
|
||||
def test_get_available_batches_qty_aggregates_by_batch(self):
|
||||
batches = [
|
||||
frappe._dict(batch_no="B1", qty=10),
|
||||
frappe._dict(batch_no="B2", qty=5),
|
||||
frappe._dict(batch_no="B1", qty=3),
|
||||
]
|
||||
agg = get_available_batches_qty(batches)
|
||||
self.assertEqual(agg["B1"], 13)
|
||||
self.assertEqual(agg["B2"], 5)
|
||||
|
||||
def test_get_type_of_transaction_derives_direction(self):
|
||||
def se(**kw):
|
||||
return get_type_of_transaction(frappe._dict(doctype="Stock Entry"), frappe._dict(**kw))
|
||||
|
||||
self.assertEqual(se(s_warehouse="W"), "Outward") # issuing from a source warehouse
|
||||
self.assertEqual(se(), "Inward") # only a target warehouse
|
||||
self.assertEqual(
|
||||
get_type_of_transaction(frappe._dict(doctype="Purchase Receipt"), frappe._dict()), "Inward"
|
||||
)
|
||||
self.assertEqual(
|
||||
get_type_of_transaction(frappe._dict(doctype="Stock Reconciliation"), frappe._dict()), "Inward"
|
||||
)
|
||||
# a purchase return reverses the direction to Outward
|
||||
self.assertEqual(
|
||||
get_type_of_transaction(frappe._dict(doctype="Purchase Receipt", is_return=1), frappe._dict()),
|
||||
"Outward",
|
||||
)
|
||||
|
||||
def test_duplicate_serial_no_in_entries_is_rejected(self):
|
||||
doc = frappe.new_doc("Serial and Batch Bundle")
|
||||
doc.append("entries", {"serial_no": "SN1"})
|
||||
doc.append("entries", {"serial_no": "SN1"})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no)
|
||||
|
||||
def test_duplicate_batch_no_in_entries_is_rejected(self):
|
||||
doc = frappe.new_doc("Serial and Batch Bundle")
|
||||
doc.append("entries", {"batch_no": "B1"})
|
||||
doc.append("entries", {"batch_no": "B1"})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_serial_and_batch_no)
|
||||
|
||||
def test_voucher_no_is_mandatory(self):
|
||||
doc = frappe.new_doc("Serial and Batch Bundle")
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_serial_and_batch_data)
|
||||
|
||||
def test_validate_docstatus_rejects_unsubmitted_entries(self):
|
||||
doc = frappe.new_doc("Serial and Batch Bundle")
|
||||
doc.append("entries", {"qty": 1}) # a fresh row has docstatus 0
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_docstatus)
|
||||
|
||||
def test_calculate_total_qty_normalizes_and_signs(self):
|
||||
inward = frappe.new_doc("Serial and Batch Bundle")
|
||||
inward.type_of_transaction = "Inward"
|
||||
inward.append("entries", {"qty": 5})
|
||||
inward.append("entries", {"qty": 3})
|
||||
inward.calculate_total_qty(save=False)
|
||||
self.assertEqual(inward.total_qty, 8)
|
||||
|
||||
# Outward flips the sign
|
||||
outward = frappe.new_doc("Serial and Batch Bundle")
|
||||
outward.type_of_transaction = "Outward"
|
||||
outward.append("entries", {"qty": 5})
|
||||
outward.calculate_total_qty(save=False)
|
||||
self.assertEqual(outward.total_qty, -5)
|
||||
|
||||
# a serialized bundle normalizes each row qty to 1
|
||||
serialized = frappe.new_doc("Serial and Batch Bundle")
|
||||
serialized.has_serial_no = 1
|
||||
serialized.type_of_transaction = "Inward"
|
||||
serialized.append("entries", {"qty": 5})
|
||||
serialized.calculate_total_qty(save=False)
|
||||
self.assertEqual(serialized.total_qty, 1)
|
||||
|
||||
def test_get_bundle_wise_serial_nos(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_bundle_wise_serial_nos,
|
||||
)
|
||||
|
||||
item_code = make_item(properties={"has_serial_no": 1, "serial_no_series": "TEST-BWSN-.#####"}).name
|
||||
|
||||
bundles = []
|
||||
for _ in range(2):
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
target="_Test Warehouse - _TC",
|
||||
qty=3,
|
||||
rate=100,
|
||||
)
|
||||
bundles.append(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
data = [frappe._dict(serial_and_batch_bundle=bundle) for bundle in bundles]
|
||||
|
||||
self.assertEqual(get_bundle_wise_serial_nos([], {}), {})
|
||||
|
||||
bundle_wise_serial_nos = get_bundle_wise_serial_nos(data, {})
|
||||
for bundle in bundles:
|
||||
self.assertEqual(sorted(bundle_wise_serial_nos[bundle]), get_serial_nos_from_bundle(bundle))
|
||||
|
||||
# check_serial_nos must restrict the result to the requested serial nos
|
||||
serial_no = get_serial_nos_from_bundle(bundles[0])[0]
|
||||
bundle_wise_serial_nos = get_bundle_wise_serial_nos(
|
||||
data, {"check_serial_nos": True, "serial_nos": [serial_no]}
|
||||
)
|
||||
|
||||
self.assertNotIn(bundles[1], bundle_wise_serial_nos)
|
||||
self.assertEqual(bundle_wise_serial_nos[bundles[0]], [serial_no])
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
"column_break_itvd",
|
||||
"enable_separate_reposting_for_gl",
|
||||
"errors_notification_section",
|
||||
"notify_reposting_error_to_role",
|
||||
"auto_reposting_section",
|
||||
"repost_incorrect_valuation_entries"
|
||||
"notify_reposting_error_to_role"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -115,24 +113,12 @@
|
||||
"fieldname": "do_not_fetch_incoming_rate_from_serial_no",
|
||||
"fieldtype": "Check",
|
||||
"label": "Do not fetch incoming rate from Serial No"
|
||||
},
|
||||
{
|
||||
"fieldname": "auto_reposting_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Auto Reposting of Incorrect Valuation"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If enabled, a weekly scheduler scans the Stock Ledger Variance for item-warehouses with incorrect valuation in the current financial year and auto-creates Item & Warehouse based reposts to fix them.",
|
||||
"fieldname": "repost_incorrect_valuation_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Repost Incorrect Valuation Entries (Weekly)"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-01 14:41:51.499245",
|
||||
"modified": "2026-05-15 12:59:34.392491",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Reposting Settings",
|
||||
|
||||
@@ -4,16 +4,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (
|
||||
add_to_date,
|
||||
get_datetime,
|
||||
get_link_to_form,
|
||||
get_time_str,
|
||||
getdate,
|
||||
time_diff_in_hours,
|
||||
today,
|
||||
)
|
||||
from frappe.utils.user import get_users_with_role
|
||||
from frappe.utils import add_to_date, get_datetime, get_time_str, time_diff_in_hours
|
||||
|
||||
|
||||
class StockRepostingSettings(Document):
|
||||
@@ -36,7 +27,6 @@ class StockRepostingSettings(Document):
|
||||
]
|
||||
no_of_parallel_reposting: DF.Int
|
||||
notify_reposting_error_to_role: DF.Link | None
|
||||
repost_incorrect_valuation_entries: DF.Check
|
||||
start_time: DF.Time | None
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -127,205 +117,3 @@ def create_repost_item_valuation(item_code, warehouse, posting_date):
|
||||
"status": "Queued",
|
||||
}
|
||||
).submit()
|
||||
|
||||
|
||||
def repost_incorrect_valuation_entries():
|
||||
"""Weekly scheduler entry point.
|
||||
|
||||
When `repost_incorrect_valuation_entries` is enabled in Stock Reposting Settings, scan each
|
||||
company's Stock Ledger Variance and Stock and Account Value Comparison reports for incorrect stock
|
||||
valuation in the current financial year and auto-create reposts to correct them. Journal Entries are
|
||||
never reposted, and warehouses pointing at a non-'Stock' account are reported to System Managers
|
||||
instead. Disabled by default; does nothing unless explicitly turned on."""
|
||||
if not frappe.db.get_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries"):
|
||||
return
|
||||
|
||||
for company in frappe.get_all("Company", pluck="name"):
|
||||
# The Stock Ledger Variance scan runs the invariant check for every item-warehouse, so process
|
||||
# each company as its own long-running background job rather than blocking the weekly scheduler.
|
||||
frappe.enqueue(
|
||||
repost_incorrect_valuation_entries_for_company,
|
||||
queue="long",
|
||||
job_id=f"repost_incorrect_valuation::{company}",
|
||||
deduplicate=True,
|
||||
company=company,
|
||||
)
|
||||
|
||||
|
||||
def repost_incorrect_valuation_entries_for_company(company):
|
||||
"""Detect and repost incorrect stock valuation for a single company, limited to the current
|
||||
financial year, using two reports:
|
||||
|
||||
1. Stock Ledger Variance - item-warehouses whose ledger valuation is internally inconsistent
|
||||
(typically a wrong previous-SLE pick). Fixed with an Item & Warehouse repost.
|
||||
2. Stock and Account Value Comparison - vouchers whose stock value does not match the accounting
|
||||
ledger. Reposted via the report's own logic (Journal Entries are excluded - ERPNext does not
|
||||
repost them). If a voucher's warehouse points at an account that is not of type 'Stock',
|
||||
reposting can never clear the difference, so System Managers are notified instead."""
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
fy_start_date = get_fiscal_year(today(), company=company)[1]
|
||||
|
||||
_repost_stock_ledger_variance(company, fy_start_date)
|
||||
_repost_stock_account_value_comparison(company, fy_start_date)
|
||||
|
||||
|
||||
def _repost_stock_ledger_variance(company, fy_start_date):
|
||||
from erpnext.stock.report.stock_ledger_variance.stock_ledger_variance import (
|
||||
get_data as get_stock_ledger_variance,
|
||||
)
|
||||
|
||||
created = []
|
||||
for row in get_stock_ledger_variance({"company": company}) or []:
|
||||
row = frappe._dict(row)
|
||||
|
||||
# Only correct issues that originate in the current financial year.
|
||||
if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date):
|
||||
continue
|
||||
|
||||
# Avoid piling up duplicate reposts week over week for the same item-warehouse.
|
||||
if has_pending_valuation_repost(company, row.item_code, row.warehouse):
|
||||
continue
|
||||
|
||||
create_repost_item_valuation(row.item_code, row.warehouse, row.posting_date)
|
||||
created.append(row)
|
||||
|
||||
if created:
|
||||
frappe.logger("stock_reposting").info(
|
||||
f"Auto-reposted {len(created)} incorrect-valuation item-warehouse(s) for {company}: "
|
||||
+ ", ".join(f"{d.item_code} @ {d.warehouse} from {d.posting_date}" for d in created)
|
||||
)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
def _repost_stock_account_value_comparison(company, fy_start_date):
|
||||
import erpnext
|
||||
|
||||
# Stock vs accounting values only exist under perpetual inventory.
|
||||
if not erpnext.is_perpetual_inventory_enabled(company):
|
||||
return
|
||||
|
||||
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
|
||||
create_reposting_entries,
|
||||
)
|
||||
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
|
||||
get_data as get_value_comparison,
|
||||
)
|
||||
|
||||
to_repost = []
|
||||
misconfigured = [] # (voucher_type, voucher_no, warehouse, account)
|
||||
|
||||
# Scope the report's DB scan to the current financial year (see get_data) instead of loading every
|
||||
# voucher ever posted and filtering in Python afterwards.
|
||||
comparison_filters = frappe._dict(company=company, from_date=fy_start_date, as_on_date=today())
|
||||
for row in get_value_comparison(comparison_filters) or []:
|
||||
row = frappe._dict(row)
|
||||
|
||||
# ERPNext does not repost Journal Entries (GL-only postings have no stock ledger to repost).
|
||||
if row.voucher_type == "Journal Entry":
|
||||
continue
|
||||
|
||||
# Only correct issues that originate in the current financial year.
|
||||
if not row.posting_date or getdate(row.posting_date) < getdate(fy_start_date):
|
||||
continue
|
||||
|
||||
# If a warehouse on this voucher is mapped to an account that is not of type 'Stock', reposting
|
||||
# can never reconcile stock vs accounting value - flag it for a human instead of reposting.
|
||||
# Only flag accounts with a concrete, non-'Stock' type. An unset/blank account_type is treated as
|
||||
# "unknown" - reposting may well reconcile it - so we don't skip the voucher or email a false alarm.
|
||||
wrong_accounts = [
|
||||
(warehouse, account)
|
||||
for warehouse, account, account_type in get_voucher_warehouse_accounts(row.voucher_no, company)
|
||||
if account_type and account_type != "Stock"
|
||||
]
|
||||
if wrong_accounts:
|
||||
misconfigured.extend(
|
||||
(row.voucher_type, row.voucher_no, warehouse, account)
|
||||
for warehouse, account in wrong_accounts
|
||||
)
|
||||
continue
|
||||
|
||||
to_repost.append(row)
|
||||
|
||||
if to_repost:
|
||||
# create_reposting_entries reposts Purchase Receipt/Invoice transaction-wise and everything else
|
||||
# item-warehouse-wise, and de-duplicates against existing reposts.
|
||||
create_reposting_entries(to_repost, company)
|
||||
frappe.logger("stock_reposting").info(
|
||||
f"Auto-reposted {len(to_repost)} stock/account value mismatch voucher(s) for {company}."
|
||||
)
|
||||
|
||||
if misconfigured:
|
||||
notify_incorrect_stock_account(company, misconfigured)
|
||||
|
||||
|
||||
def get_voucher_warehouse_accounts(voucher_no, company):
|
||||
"""Return (warehouse, account, account_type) for each distinct warehouse the voucher posted stock
|
||||
into, so the caller can verify the account is a 'Stock' asset account."""
|
||||
warehouses = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={"voucher_no": voucher_no, "is_cancelled": 0},
|
||||
pluck="warehouse",
|
||||
distinct=True,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for warehouse in {w for w in warehouses if w}:
|
||||
account = frappe.get_cached_value("Warehouse", warehouse, "account") or frappe.get_cached_value(
|
||||
"Company", company, "default_inventory_account"
|
||||
)
|
||||
account_type = frappe.get_cached_value("Account", account, "account_type") if account else None
|
||||
rows.append((warehouse, account, account_type))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def notify_incorrect_stock_account(company, misconfigured):
|
||||
"""Email System Managers about warehouse accounts that are not of type 'Stock', which keep stock
|
||||
and accounting values from reconciling even after reposting."""
|
||||
recipients = get_users_with_role("System Manager")
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
items = "".join(
|
||||
"<li>{} {} → {}: {}</li>".format(
|
||||
voucher_type,
|
||||
get_link_to_form(voucher_type, voucher_no),
|
||||
warehouse,
|
||||
account or _("No account set"),
|
||||
)
|
||||
for voucher_type, voucher_no, warehouse, account in misconfigured
|
||||
)
|
||||
|
||||
subject = _("Incorrect Stock Asset Account in {0}").format(company)
|
||||
message = (
|
||||
_("Stock and accounting values could not be reconciled by reposting for {0}.").format(
|
||||
frappe.bold(company)
|
||||
)
|
||||
+ "<br><br>"
|
||||
+ _(
|
||||
"The warehouse account(s) below are not of type 'Stock'. Please set a correct Stock asset account on the warehouse (Account Type must be 'Stock'):"
|
||||
)
|
||||
+ f"<ul>{items}</ul>"
|
||||
)
|
||||
|
||||
frappe.sendmail(recipients=recipients, subject=subject, message=message)
|
||||
|
||||
|
||||
def has_pending_valuation_repost(company, item_code, warehouse):
|
||||
"""True if an Item & Warehouse repost for this item-warehouse is already queued or running, so the
|
||||
weekly job does not stack duplicate reposts."""
|
||||
return bool(
|
||||
frappe.db.exists(
|
||||
"Repost Item Valuation",
|
||||
{
|
||||
"company": company,
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"based_on": "Item and Warehouse",
|
||||
"status": ("in", ["Queued", "In Progress"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,137 +1,14 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, today
|
||||
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import get_recipients
|
||||
from erpnext.stock.doctype.stock_reposting_settings import stock_reposting_settings as srs
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
TEST_COMPANY = "_Test Company"
|
||||
TEST_WAREHOUSE = "_Test Warehouse - _TC"
|
||||
|
||||
|
||||
class TestStockRepostingSettings(ERPNextTestSuite):
|
||||
def tearDown(self):
|
||||
frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0)
|
||||
super().tearDown()
|
||||
|
||||
def test_auto_repost_disabled_does_nothing(self):
|
||||
frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 0)
|
||||
with patch("frappe.enqueue") as enqueue:
|
||||
srs.repost_incorrect_valuation_entries()
|
||||
enqueue.assert_not_called()
|
||||
|
||||
def test_auto_repost_enabled_enqueues_per_company(self):
|
||||
frappe.db.set_single_value("Stock Reposting Settings", "repost_incorrect_valuation_entries", 1)
|
||||
with patch("frappe.enqueue") as enqueue:
|
||||
srs.repost_incorrect_valuation_entries()
|
||||
self.assertTrue(enqueue.called)
|
||||
# one job per company
|
||||
self.assertEqual(enqueue.call_count, frappe.db.count("Company"))
|
||||
|
||||
def test_reposts_only_current_financial_year_entries(self):
|
||||
item = make_item().name
|
||||
fy_start_date = get_fiscal_year(today(), company=TEST_COMPANY)[1]
|
||||
|
||||
current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()}
|
||||
prior_fy_row = {
|
||||
"item_code": item,
|
||||
"warehouse": TEST_WAREHOUSE,
|
||||
"posting_date": add_days(fy_start_date, -1),
|
||||
}
|
||||
|
||||
calls = []
|
||||
variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data"
|
||||
with (
|
||||
patch.object(
|
||||
srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d)))
|
||||
),
|
||||
patch(variance_path, return_value=[current_fy_row, prior_fy_row]),
|
||||
):
|
||||
srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY)
|
||||
|
||||
# Only the current-FY entry is reposted; the prior-FY one is ignored.
|
||||
self.assertEqual(calls, [(item, TEST_WAREHOUSE, str(today()))])
|
||||
|
||||
def test_skips_when_repost_already_pending(self):
|
||||
item = make_item().name
|
||||
current_fy_row = {"item_code": item, "warehouse": TEST_WAREHOUSE, "posting_date": today()}
|
||||
|
||||
calls = []
|
||||
variance_path = "erpnext.stock.report.stock_ledger_variance.stock_ledger_variance.get_data"
|
||||
with (
|
||||
patch.object(srs, "has_pending_valuation_repost", return_value=True),
|
||||
patch.object(
|
||||
srs, "create_repost_item_valuation", side_effect=lambda i, w, d: calls.append((i, w, str(d)))
|
||||
),
|
||||
patch(variance_path, return_value=[current_fy_row]),
|
||||
):
|
||||
srs.repost_incorrect_valuation_entries_for_company(TEST_COMPANY)
|
||||
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_value_comparison_excludes_je_and_flags_wrong_account(self):
|
||||
fy_start = getdate(today())
|
||||
|
||||
rows = [
|
||||
# correct stock account, current FY -> reposted
|
||||
{"voucher_type": "Purchase Receipt", "voucher_no": "PR-OK", "posting_date": today()},
|
||||
# Journal Entry -> never reposted
|
||||
{"voucher_type": "Journal Entry", "voucher_no": "JE-1", "posting_date": today()},
|
||||
# warehouse account not of type "Stock" -> notify, not reposted
|
||||
{"voucher_type": "Purchase Receipt", "voucher_no": "PR-BADACC", "posting_date": today()},
|
||||
# warehouse account with an unset account_type -> unknown, repost (not a false alarm)
|
||||
{"voucher_type": "Purchase Receipt", "voucher_no": "PR-NOTYPE", "posting_date": today()},
|
||||
# prior financial year -> ignored
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": "PR-OLD",
|
||||
"posting_date": add_days(fy_start, -1),
|
||||
},
|
||||
]
|
||||
accounts = {
|
||||
"PR-OK": [("WH-A", "Stock A - _TC", "Stock")],
|
||||
"PR-BADACC": [("WH-B", "Debtors - _TC", "Receivable")],
|
||||
"PR-NOTYPE": [("WH-C", "Unclassified - _TC", None)],
|
||||
"PR-OLD": [("WH-A", "Stock A - _TC", "Stock")],
|
||||
}
|
||||
|
||||
reposted = {}
|
||||
sent = []
|
||||
comparison = (
|
||||
"erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison"
|
||||
)
|
||||
with (
|
||||
patch("erpnext.is_perpetual_inventory_enabled", return_value=True),
|
||||
patch(f"{comparison}.get_data", return_value=rows),
|
||||
patch(
|
||||
f"{comparison}.create_reposting_entries",
|
||||
side_effect=lambda r, c: reposted.update(rows=r, company=c),
|
||||
),
|
||||
patch.object(
|
||||
srs, "get_voucher_warehouse_accounts", side_effect=lambda vno, c: accounts.get(vno, [])
|
||||
),
|
||||
patch.object(srs, "get_users_with_role", return_value=["sysmgr@test.com"]),
|
||||
patch("frappe.sendmail", side_effect=lambda **kw: sent.append(kw)),
|
||||
):
|
||||
srs._repost_stock_account_value_comparison(TEST_COMPANY, fy_start)
|
||||
|
||||
# Current-FY, non-Journal-Entry vouchers are reposted: the correct-account one and the one whose
|
||||
# account_type is unset (unknown is treated as "proceed", not "wrong account").
|
||||
self.assertEqual([r["voucher_no"] for r in reposted["rows"]], ["PR-OK", "PR-NOTYPE"])
|
||||
# Only the concrete wrong-account voucher triggers a System Manager notification.
|
||||
self.assertEqual(len(sent), 1)
|
||||
self.assertIn("PR-BADACC", sent[0]["message"])
|
||||
self.assertNotIn("PR-NOTYPE", sent[0]["message"])
|
||||
|
||||
|
||||
class TestStockRepostingSettingsNotification(ERPNextTestSuite):
|
||||
def test_notify_reposting_error_to_role(self):
|
||||
role = "Notify Reposting Role"
|
||||
|
||||
|
||||
@@ -1316,7 +1316,6 @@ class StockReservation:
|
||||
"voucher_type": entry.voucher_type or to_doctype,
|
||||
"voucher_no": entry.voucher_no,
|
||||
"voucher_detail_no": entry.voucher_detail_no,
|
||||
"stock_uom": entry.stock_uom,
|
||||
"serial_nos": [],
|
||||
"sre_names": defaultdict(float),
|
||||
"batches": defaultdict(float),
|
||||
@@ -1374,7 +1373,6 @@ class StockReservation:
|
||||
sre.voucher_qty = entry.required_qty
|
||||
sre.item_code = entry.item_code
|
||||
sre.warehouse = entry.warehouse
|
||||
sre.stock_uom = entry.stock_uom
|
||||
sre.reserved_qty = min(sre.available_qty, entry.qty)
|
||||
sre.has_serial_no = frappe.get_value("Item", sre.item_code, "has_serial_no")
|
||||
sre.has_batch_no = frappe.get_value("Item", sre.item_code, "has_batch_no")
|
||||
|
||||
@@ -697,66 +697,6 @@ class TestStockReservationEntry(ERPNextTestSuite):
|
||||
# Test - 1: ValidationError should be thrown as the inwarded stock is reserved.
|
||||
self.assertRaises(frappe.ValidationError, se.cancel)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"allow_negative_stock": 0,
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_reserve_serial_and_batch": 1,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
"use_serial_batch_fields": 1,
|
||||
},
|
||||
)
|
||||
def test_batch_shared_across_sales_orders_can_be_delivered(self) -> None:
|
||||
# Regression (#57159): one batch reserved by two Sales Orders. Delivering each order's own
|
||||
# reserved unit must not raise Reserved Batch Conflict — the remainder covers the other order.
|
||||
# The batch is set on the row explicitly as the v16 reserved-stock mapper does not carry
|
||||
# the reserved batch onto the Delivery Note row.
|
||||
item_doc = make_batch_item()
|
||||
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
|
||||
|
||||
orders = []
|
||||
for _i in range(2):
|
||||
so = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=1, rate=100)
|
||||
so.create_stock_reservation_entries()
|
||||
orders.append(so)
|
||||
|
||||
(batch_no,) = get_reserved_batch_nos(orders[0].name) | get_reserved_batch_nos(orders[1].name)
|
||||
|
||||
for so in orders:
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.items[0].batch_no = batch_no
|
||||
dn.save()
|
||||
dn.submit()
|
||||
self.assertEqual(dn.docstatus, 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{
|
||||
"allow_negative_stock": 0,
|
||||
"enable_stock_reservation": 1,
|
||||
"auto_reserve_serial_and_batch": 1,
|
||||
"pick_serial_and_batch_based_on": "FIFO",
|
||||
"use_serial_batch_fields": 1,
|
||||
},
|
||||
)
|
||||
def test_delivery_draining_a_batch_reserved_for_another_sales_order_is_blocked(self) -> None:
|
||||
# Guard for #57159 fix: an order without a reservation must still be blocked from draining
|
||||
# a batch below what another order has reserved from it, even if other batches have stock.
|
||||
item_doc = make_batch_item()
|
||||
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
|
||||
create_material_receipt(items={item_doc.name: item_doc}, warehouse=self.warehouse, qty=2)
|
||||
|
||||
so_a = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100)
|
||||
so_a.create_stock_reservation_entries()
|
||||
(reserved_batch_no,) = get_reserved_batch_nos(so_a.name)
|
||||
|
||||
so_b = make_sales_order(item_code=item_doc.name, warehouse=self.warehouse, qty=2, rate=100)
|
||||
dn = make_delivery_note(so_b.name)
|
||||
dn.items[0].batch_no = reserved_batch_no
|
||||
dn.save()
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is reserved for", dn.submit)
|
||||
|
||||
|
||||
def create_items() -> dict:
|
||||
items_properties = [
|
||||
@@ -797,33 +737,6 @@ def create_items() -> dict:
|
||||
return items
|
||||
|
||||
|
||||
def make_batch_item():
|
||||
return make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"valuation_rate": 100,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "SRBI-.#####.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_reserved_batch_nos(sales_order: str) -> set:
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
|
||||
batch_nos = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(sb_entry.batch_no)
|
||||
.where((sre.voucher_no == sales_order) & (sre.docstatus == 1))
|
||||
).run(pluck=True)
|
||||
|
||||
return set(batch_nos)
|
||||
|
||||
|
||||
def create_material_receipt(
|
||||
items: dict, warehouse: str = "_Test Warehouse - _TC", qty: float = 100
|
||||
) -> StockEntry:
|
||||
|
||||
@@ -307,9 +307,10 @@ def update_bin_details(ctx: ItemDetailsCtx, out: ItemDetails, doc):
|
||||
out.update(get_bin_details(ctx.item_code, ctx.from_warehouse))
|
||||
|
||||
elif out.get("warehouse"):
|
||||
bin_details = get_bin_details(
|
||||
ctx.item_code, out.warehouse, ctx.company, include_child_warehouses=True
|
||||
)
|
||||
company = ctx.company if (doc and doc.get("doctype") == "Purchase Order") else None
|
||||
|
||||
# calculate company_total_stock only for po
|
||||
bin_details = get_bin_details(ctx.item_code, out.warehouse, company, include_child_warehouses=True)
|
||||
|
||||
out.update(bin_details)
|
||||
|
||||
|
||||
@@ -287,7 +287,6 @@ class FIFOSlots:
|
||||
self.serial_no_details = {}
|
||||
self.batch_no_details = {}
|
||||
self.batchwise_valuation_by_batch = {}
|
||||
self.valuation_method_by_item = {}
|
||||
self.filters = filters
|
||||
self.sle = sle
|
||||
|
||||
@@ -308,10 +307,9 @@ class FIFOSlots:
|
||||
self.prepare_stock_reco_voucher_wise_count()
|
||||
|
||||
if stock_ledger_entries is None:
|
||||
# streaming path: nested queries invalidate the streaming cursor below,
|
||||
# so batchwise valuation flags and item valuation methods must be resolved beforehand
|
||||
# nested queries invalidate the streaming cursor below,
|
||||
# so batchwise valuation flags must be resolved beforehand
|
||||
self._prefetch_batchwise_valuations()
|
||||
self._prefetch_valuation_methods()
|
||||
|
||||
with frappe.db.unbuffered_cursor():
|
||||
if stock_ledger_entries is None:
|
||||
@@ -323,28 +321,12 @@ class FIFOSlots:
|
||||
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
|
||||
del stock_ledger_entries
|
||||
|
||||
self._recompute_moving_average_slots()
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
self.item_details = self._aggregate_details_by_item(self.item_details)
|
||||
|
||||
return self.item_details
|
||||
|
||||
def _recompute_moving_average_slots(self) -> None:
|
||||
for item_dict in self.item_details.values():
|
||||
if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"):
|
||||
continue
|
||||
|
||||
details = item_dict["details"]
|
||||
if self._get_item_valuation_method(details.name) != "Moving Average":
|
||||
continue
|
||||
|
||||
rate = flt(details.valuation_rate)
|
||||
for slot in item_dict["fifo_queue"]:
|
||||
if is_qty_slot(slot):
|
||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
||||
|
||||
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||
if stock_ledger_entries is not None:
|
||||
return frappe._dict({}), frappe._dict({})
|
||||
@@ -365,10 +347,7 @@ class FIFOSlots:
|
||||
if row.actual_qty > 0:
|
||||
self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
||||
else:
|
||||
from_end = self._get_item_valuation_method(row.name) == "LIFO"
|
||||
self._compute_outgoing_stock(
|
||||
row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end
|
||||
)
|
||||
self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
||||
|
||||
self._update_balances(row, key)
|
||||
self._trim_serial_fifo_queue(row, key, fifo_queue)
|
||||
@@ -481,45 +460,6 @@ class FIFOSlots:
|
||||
for batch_no, use_batchwise_valuation in query.run():
|
||||
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
|
||||
|
||||
def _get_item_valuation_method(self, item_code: str) -> str:
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if item_code not in self.valuation_method_by_item:
|
||||
# only reachable when stock ledger entries are passed in directly;
|
||||
# the streaming path prefetches all methods before iteration
|
||||
self.valuation_method_by_item[item_code] = get_valuation_method(
|
||||
item_code, self.filters.get("company")
|
||||
)
|
||||
|
||||
return self.valuation_method_by_item[item_code]
|
||||
|
||||
def _prefetch_valuation_methods(self) -> None:
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
company = self.filters.get("company")
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
item = frappe.qb.DocType("Item")
|
||||
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.inner_join(item)
|
||||
.on(sle.item_code == item.name)
|
||||
.select(item.name, item.valuation_method)
|
||||
.distinct()
|
||||
.where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1))
|
||||
)
|
||||
query = self._apply_filter(query, sle, "item_code")
|
||||
|
||||
# items with no item-level method share the company/settings default; resolve it once
|
||||
default_method = None
|
||||
for item_code, valuation_method in query.run():
|
||||
if not valuation_method:
|
||||
if default_method is None:
|
||||
default_method = get_valuation_method(item_code, company)
|
||||
valuation_method = default_method
|
||||
self.valuation_method_by_item[item_code] = valuation_method
|
||||
|
||||
def _init_key_stores(self, row: dict) -> tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
@@ -636,13 +576,7 @@ class FIFOSlots:
|
||||
fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference)
|
||||
|
||||
def _compute_outgoing_stock(
|
||||
self,
|
||||
row: dict,
|
||||
fifo_queue: list,
|
||||
transfer_key: tuple,
|
||||
serial_nos: list,
|
||||
batch_nos: list,
|
||||
from_end: bool = False,
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list
|
||||
):
|
||||
"Update FIFO Queue on outward stock."
|
||||
if serial_nos:
|
||||
@@ -650,7 +584,7 @@ class FIFOSlots:
|
||||
elif batch_nos:
|
||||
self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos)
|
||||
else:
|
||||
self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end)
|
||||
self._consume_fifo_slots(row, fifo_queue, transfer_key)
|
||||
|
||||
def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None:
|
||||
fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos]
|
||||
@@ -727,23 +661,19 @@ class FIFOSlots:
|
||||
)
|
||||
self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference])
|
||||
|
||||
def _consume_fifo_slots(
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False
|
||||
) -> None:
|
||||
# LIFO consumes the most recent inward first, so pop from the tail instead of the head.
|
||||
index = -1 if from_end else 0
|
||||
def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None:
|
||||
qty_to_pop = abs(row.actual_qty)
|
||||
stock_value = abs(row.stock_value_difference)
|
||||
|
||||
while qty_to_pop:
|
||||
slot = fifo_queue[index] if fifo_queue else [0, None, 0]
|
||||
slot = fifo_queue[0] if fifo_queue else [0, None, 0]
|
||||
slot_qty = flt(slot[FIFO_QTY_INDEX])
|
||||
slot_value = flt(slot[FIFO_VALUE_INDEX])
|
||||
|
||||
if 0 < slot_qty <= qty_to_pop:
|
||||
qty_to_pop -= slot_qty
|
||||
stock_value -= slot_value
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(index))
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
|
||||
elif not fifo_queue:
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
|
||||
self.transferred_item_details[transfer_key].append(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
|
||||
@@ -65,131 +63,6 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30
|
||||
|
||||
def test_moving_average_value_ties_to_stock_balance(self):
|
||||
"""For Moving Average items the queue value is re-derived as qty * rate so the
|
||||
report's stock value ties to Stock Balance, instead of stranding a residual
|
||||
from FIFO-by-qty consumption vs blended outgoing value."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=1000,
|
||||
valuation_rate=100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=2000,
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=(-1500),
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=(-5),
|
||||
qty_after_transaction=5,
|
||||
stock_value_difference=(-750),
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
]
|
||||
|
||||
with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
queue = slots["MA Item"]["fifo_queue"]
|
||||
total_value = sum(slot[2] for slot in queue)
|
||||
|
||||
# Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150
|
||||
self.assertEqual(total_value, 750.0)
|
||||
|
||||
def test_lifo_consumes_newest_first(self):
|
||||
"""LIFO items consume the most recent inward first, so the oldest lot stays on
|
||||
hand. The remaining queue, stock value and average age must reflect the older
|
||||
stock, unlike the default FIFO which retains the newest lots."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=30,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=50,
|
||||
stock_value_difference=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=40,
|
||||
stock_value_difference=(-10),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
]
|
||||
|
||||
with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
queue = slots["LIFO Item"]["fifo_queue"]
|
||||
|
||||
# newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10
|
||||
self.assertEqual(queue[0][0], 30.0)
|
||||
self.assertEqual(queue[-1][0], 10.0)
|
||||
self.assertEqual(sum(slot[0] for slot in queue), 40.0)
|
||||
self.assertEqual(sum(slot[2] for slot in queue), 40.0)
|
||||
|
||||
# average age skews older than the FIFO result (8.5) because the old lot is retained
|
||||
self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75)
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
|
||||
@@ -33,11 +33,6 @@ def get_data(report_filters):
|
||||
"posting_date": ("<=", report_filters.as_on_date),
|
||||
}
|
||||
|
||||
# Optional lower bound: lets callers (e.g. the weekly auto-repost job) scope the scan to the current
|
||||
# fiscal year in the query itself instead of loading every voucher ever posted and filtering later.
|
||||
if report_filters.get("from_date"):
|
||||
filters["posting_date"] = ("between", [report_filters.from_date, report_filters.as_on_date])
|
||||
|
||||
get_currency_precision() or 2
|
||||
stock_ledger_entries = get_stock_ledger_data(report_filters, filters)
|
||||
voucher_wise_gl_data = get_gl_data(report_filters, filters)
|
||||
@@ -245,26 +240,19 @@ def repost_based_on_transaction(rows, company=None, entries=None):
|
||||
continue
|
||||
|
||||
duplicate_vouchers.add(voucher_key)
|
||||
# Isolate each submit in a savepoint: an already-queued repost raises DuplicateEntryError, and on
|
||||
# PostgreSQL a failed insert aborts the whole transaction, killing the rest of the loop (and the
|
||||
# silent weekly job). Rolling back to the savepoint keeps prior/next reposts intact.
|
||||
frappe.db.savepoint("repost_based_on_transaction")
|
||||
try:
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Transaction",
|
||||
"status": "Queued",
|
||||
"voucher_type": row.get("voucher_type"),
|
||||
"voucher_no": row.get("voucher_no"),
|
||||
"posting_date": row.get("posting_date"),
|
||||
"posting_time": row.get("posting_time"),
|
||||
"company": company,
|
||||
"allow_nagative_stock": 1,
|
||||
"recalculate_valuation_rate": 1,
|
||||
}
|
||||
).submit()
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Transaction",
|
||||
"status": "Queued",
|
||||
"voucher_type": row.get("voucher_type"),
|
||||
"voucher_no": row.get("voucher_no"),
|
||||
"posting_date": row.get("posting_date"),
|
||||
"posting_time": row.get("posting_time"),
|
||||
"company": company,
|
||||
"allow_nagative_stock": 1,
|
||||
"recalculate_valuation_rate": 1,
|
||||
}
|
||||
).submit()
|
||||
|
||||
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
|
||||
except frappe.DuplicateEntryError:
|
||||
frappe.db.rollback(save_point="repost_based_on_transaction")
|
||||
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
|
||||
|
||||
@@ -27,40 +27,6 @@ class TestGetItemDetail(ERPNextTestSuite):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("price_list_rate"), 100)
|
||||
|
||||
def test_bin_details_for_selling_doctypes(self):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
item_code = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse - _TC", qty=100, rate=100)
|
||||
make_purchase_receipt(item_code=item_code, warehouse="_Test Warehouse 1 - _TC", qty=50, rate=100)
|
||||
|
||||
args = frappe._dict(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"company": "_Test Company",
|
||||
"customer": "_Test Customer",
|
||||
"currency": "INR",
|
||||
"conversion_rate": 1.0,
|
||||
"price_list": "_Test Price List",
|
||||
"price_list_currency": "INR",
|
||||
"plc_conversion_rate": 1.0,
|
||||
"transaction_date": None,
|
||||
"name": None,
|
||||
"ignore_pricing_rule": 1,
|
||||
"qty": 1,
|
||||
}
|
||||
)
|
||||
|
||||
for doctype in ("Sales Order", "Quotation", "Sales Invoice", "Delivery Note", "Purchase Order"):
|
||||
with self.subTest(doctype=doctype):
|
||||
details = get_item_details(args.copy().update({"doctype": doctype}))
|
||||
|
||||
self.assertEqual(details.get("actual_qty"), 100)
|
||||
self.assertEqual(details.get("company_total_stock"), 150)
|
||||
|
||||
# making this test in get_item_details test file as feat/fix is present in that method
|
||||
def test_fetch_price_from_list_rate_on_doc_save(self):
|
||||
# create item
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user