mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-17 12:18:49 +00:00
Compare commits
148 Commits
v15.115.0
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88443e4a97 | ||
|
|
5e539643ec | ||
|
|
fd01171df5 | ||
|
|
4148d32ea5 | ||
|
|
74dd90faa6 | ||
|
|
9414230671 | ||
|
|
1b6ff72f0f | ||
|
|
314dd16aa3 | ||
|
|
b1e47eb97e | ||
|
|
4f9ea989c4 | ||
|
|
77ec6447c3 | ||
|
|
ff6c8bbb44 | ||
|
|
93931ac91b | ||
|
|
6b23b007a4 | ||
|
|
202f52271c | ||
|
|
654890dce8 | ||
|
|
218f783a26 | ||
|
|
5e2e15436d | ||
|
|
f179c91036 | ||
|
|
30ab2dba6e | ||
|
|
82f2af3a0e | ||
|
|
1bbeec6bfa | ||
|
|
fc4ac7a441 | ||
|
|
3913b9631f | ||
|
|
9de9228cb9 | ||
|
|
dcff9164c4 | ||
|
|
aa4cc4c97d | ||
|
|
8086542885 | ||
|
|
cc457433fe | ||
|
|
687fed031b | ||
|
|
e4d2a960d2 | ||
|
|
ce62ef3e2e | ||
|
|
bb5be10372 | ||
|
|
fcd0350652 | ||
|
|
1489258ea0 | ||
|
|
674704cbfd | ||
|
|
a6a887f833 | ||
|
|
e8cdad1c6b | ||
|
|
808109abc0 | ||
|
|
ace51fccfa | ||
|
|
8bd35d2255 | ||
|
|
80548a1282 | ||
|
|
94d1fd6c42 | ||
|
|
001f3a36ab | ||
|
|
b7f276989d | ||
|
|
2e72d22328 | ||
|
|
cfc546cf3f | ||
|
|
ede2a68afd | ||
|
|
29179240cf | ||
|
|
bd6269918f | ||
|
|
1cb4307578 | ||
|
|
3e2a734414 | ||
|
|
0f6b2b68fa | ||
|
|
8688f1f51a | ||
|
|
9d654b7890 | ||
|
|
755e151764 | ||
|
|
f14615e826 | ||
|
|
12b826484b | ||
|
|
9c0ea9ffbb | ||
|
|
c89f9720bc | ||
|
|
d2d5fae033 | ||
|
|
4091188908 | ||
|
|
0f688a1841 | ||
|
|
6b022a5a7e | ||
|
|
3c18b172a4 | ||
|
|
d239273ce5 | ||
|
|
4c8a51d54d | ||
|
|
c10b86d538 | ||
|
|
de6a157773 | ||
|
|
8bfba5fcf3 | ||
|
|
53af4d53ef | ||
|
|
afa72884bb | ||
|
|
cb65b55568 | ||
|
|
62ce6c2129 | ||
|
|
6484f0de54 | ||
|
|
1824c59624 | ||
|
|
cef69b05a9 | ||
|
|
902a3c5c55 | ||
|
|
fde7cd5345 | ||
|
|
de0c75bcba | ||
|
|
7f4c68c1da | ||
|
|
989a6b507d | ||
|
|
f119080eb3 | ||
|
|
99bdef6baf | ||
|
|
cd612434a4 | ||
|
|
4a036b6a0b | ||
|
|
02168b7b3d | ||
|
|
55e0d106c9 | ||
|
|
2ed6f25d94 | ||
|
|
0924773856 | ||
|
|
983ec5660c | ||
|
|
0a5daeed57 | ||
|
|
95b3bd4e1d | ||
|
|
d0f29405d0 | ||
|
|
8b4bc37a32 | ||
|
|
51669501ab | ||
|
|
1239986756 | ||
|
|
3df52c7fc6 | ||
|
|
d62831d885 | ||
|
|
1fe9d569d6 | ||
|
|
6165d2fa94 | ||
|
|
ec1ca6ba95 | ||
|
|
dcea09ea8f | ||
|
|
6945e1292b | ||
|
|
1ee8904a94 | ||
|
|
ae43f47c39 | ||
|
|
75a814de36 | ||
|
|
5cedc50c55 | ||
|
|
da623eb124 | ||
|
|
ece1c9872f | ||
|
|
47121cecb3 | ||
|
|
93f33cc5cf | ||
|
|
0ab94a929a | ||
|
|
cecfcf82e0 | ||
|
|
1a52c58666 | ||
|
|
b170913880 | ||
|
|
3e6a5b3a0b | ||
|
|
db4136b258 | ||
|
|
f3dd90bb0a | ||
|
|
3c47adb021 | ||
|
|
19fc8b8e6f | ||
|
|
1cf94f5501 | ||
|
|
b0446b6a09 | ||
|
|
5666fd1334 | ||
|
|
b7e0331d56 | ||
|
|
16b90ab20f | ||
|
|
44a7013ab4 | ||
|
|
ac2e69fbfd | ||
|
|
080b8d5183 | ||
|
|
7f2fb78d94 | ||
|
|
5f14f2ccd6 | ||
|
|
4a6587b48e | ||
|
|
a9330e8900 | ||
|
|
71a2d6e43d | ||
|
|
a0b527d019 | ||
|
|
f139466851 | ||
|
|
9d3f9cce36 | ||
|
|
38b7042481 | ||
|
|
5ee3a183b3 | ||
|
|
a6d790f321 | ||
|
|
63e85c6551 | ||
|
|
43ff69429f | ||
|
|
2ed7c84d0a | ||
|
|
bd41e741be | ||
|
|
30efbf8c9b | ||
|
|
1cba32eb1a | ||
|
|
ebb7b37558 | ||
|
|
29dd6e6681 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.115.0"
|
||||
__version__ = "15.112.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -406,8 +406,7 @@
|
||||
"Customer Deposits": {
|
||||
"account_number": "2500",
|
||||
"is_group": 0,
|
||||
"root_type": "Liability",
|
||||
"account_type": "Payable"
|
||||
"root_type": "Liability"
|
||||
}
|
||||
},
|
||||
"Non Current Liabilities": {
|
||||
|
||||
@@ -115,7 +115,7 @@ def get_party_bank_account(party_type, party):
|
||||
)
|
||||
|
||||
|
||||
def get_default_company_bank_account(company, party_type, party):
|
||||
def get_default_company_bank_account(company, party_type, party, ignore_permissions=True):
|
||||
default_company_bank_account = frappe.db.get_value(party_type, party, "default_bank_account")
|
||||
if default_company_bank_account:
|
||||
if company != frappe.get_cached_value("Bank Account", default_company_bank_account, "company"):
|
||||
@@ -126,6 +126,14 @@ def get_default_company_bank_account(company, party_type, party):
|
||||
"Bank Account", {"company": company, "is_company_account": 1, "is_default": 1}
|
||||
)
|
||||
|
||||
if not ignore_permissions:
|
||||
default_company_bank_account = (
|
||||
default_company_bank_account
|
||||
if default_company_bank_account
|
||||
and frappe.get_cached_doc("Bank Account", default_company_bank_account).has_permission("select")
|
||||
else None
|
||||
)
|
||||
|
||||
return default_company_bank_account
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,36 @@ class TestBankTransaction(FrappeTestCase):
|
||||
self.assertEqual(bank_transaction.unallocated_amount, 1700)
|
||||
self.assertEqual(bank_transaction.payment_entries, [])
|
||||
|
||||
# Amending a reconciled payment entry must not carry over its clearance date
|
||||
def test_clearance_date_cleared_on_amend(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
"Bank Transaction",
|
||||
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
|
||||
)
|
||||
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
|
||||
vouchers = json.dumps(
|
||||
[
|
||||
{
|
||||
"payment_doctype": "Payment Entry",
|
||||
"payment_name": payment.name,
|
||||
"amount": bank_transaction.unallocated_amount,
|
||||
}
|
||||
]
|
||||
)
|
||||
reconcile_vouchers(bank_transaction.name, vouchers)
|
||||
|
||||
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
|
||||
|
||||
payment.reload()
|
||||
payment.cancel()
|
||||
|
||||
amended = frappe.copy_doc(payment)
|
||||
amended.amended_from = payment.name
|
||||
amended.docstatus = 0
|
||||
amended.insert()
|
||||
|
||||
self.assertFalse(amended.clearance_date)
|
||||
|
||||
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
|
||||
def test_debit_credit_output(self):
|
||||
bank_transaction = frappe.get_doc(
|
||||
|
||||
@@ -215,6 +215,7 @@ 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,23 +169,10 @@ frappe.ui.form.on("Dunning", {
|
||||
},
|
||||
get_dunning_letter_text: function (frm) {
|
||||
if (frm.doc.dunning_type) {
|
||||
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", "");
|
||||
}
|
||||
},
|
||||
frm.call("get_dunning_letter_text").then((r) => {
|
||||
if (!r.exc) {
|
||||
frm.refresh_fields();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -156,6 +156,46 @@ 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 (
|
||||
@@ -234,35 +274,3 @@ 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,7 +3,10 @@
|
||||
|
||||
|
||||
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):
|
||||
@@ -30,3 +33,134 @@ 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,9 +1,199 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestDunningType(unittest.TestCase):
|
||||
pass
|
||||
def make_dunning_type(dunning_type, company="_Test Company", **kwargs):
|
||||
doc = frappe.new_doc("Dunning Type")
|
||||
doc.dunning_type = dunning_type
|
||||
doc.company = company
|
||||
doc.dunning_fee = kwargs.get("dunning_fee", 100)
|
||||
doc.rate_of_interest = kwargs.get("rate_of_interest", 5)
|
||||
doc.is_default = kwargs.get("is_default", 0)
|
||||
|
||||
if "income_account" in kwargs:
|
||||
doc.income_account = kwargs["income_account"]
|
||||
elif kwargs.get("income_account") is not False:
|
||||
doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1"
|
||||
|
||||
if "cost_center" in kwargs:
|
||||
doc.cost_center = kwargs["cost_center"]
|
||||
elif kwargs.get("cost_center") is not False:
|
||||
doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1"
|
||||
|
||||
for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]):
|
||||
doc.append("dunning_letter_text", row)
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
class TestDunningType(FrappeTestCase):
|
||||
def test_income_account_must_belong_to_company(self):
|
||||
doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
|
||||
|
||||
def test_income_account_must_not_be_disabled(self):
|
||||
disabled_account = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "_Test Disabled Income Account",
|
||||
"parent_account": "Direct Income - _TC",
|
||||
"company": "_Test Company",
|
||||
"account_type": "Income Account",
|
||||
"disabled": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
|
||||
|
||||
def test_income_account_must_be_income_type(self):
|
||||
doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert)
|
||||
|
||||
def test_income_account_must_not_be_group(self):
|
||||
doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert)
|
||||
|
||||
def test_income_account_is_optional(self):
|
||||
doc = make_dunning_type("_Test Dunning No Income Account", income_account=False)
|
||||
doc.insert()
|
||||
self.assertFalse(doc.income_account)
|
||||
|
||||
def test_valid_income_account_passes(self):
|
||||
doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC")
|
||||
doc.insert()
|
||||
self.assertEqual(doc.income_account, "Sales - _TC")
|
||||
|
||||
def test_cost_center_must_belong_to_company(self):
|
||||
doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
|
||||
|
||||
def test_cost_center_must_not_be_disabled(self):
|
||||
disabled_cc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Disabled Cost Center",
|
||||
"parent_cost_center": "_Test Company - _TC",
|
||||
"company": "_Test Company",
|
||||
"disabled": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
|
||||
|
||||
def test_cost_center_must_not_be_group(self):
|
||||
doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert)
|
||||
|
||||
def test_cost_center_is_optional(self):
|
||||
doc = make_dunning_type("_Test Dunning No CC", cost_center=False)
|
||||
doc.insert()
|
||||
self.assertFalse(doc.cost_center)
|
||||
|
||||
def test_valid_cost_center_passes(self):
|
||||
doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC")
|
||||
doc.insert()
|
||||
self.assertEqual(doc.cost_center, "Main - _TC")
|
||||
|
||||
def test_duplicate_languages_not_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Duplicate Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one"},
|
||||
{"language": "en", "body_text": "Body two"},
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert)
|
||||
|
||||
def test_unique_languages_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Unique Languages",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one"},
|
||||
{"language": "de", "body_text": "Body two"},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertEqual(len(doc.dunning_letter_text), 2)
|
||||
|
||||
def test_only_one_default_language_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Multiple Default Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one", "is_default_language": 1},
|
||||
{"language": "de", "body_text": "Body two", "is_default_language": 1},
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "languages are marked as default languages", doc.insert
|
||||
)
|
||||
|
||||
def test_single_default_language_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Single Default Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one", "is_default_language": 1},
|
||||
{"language": "de", "body_text": "Body two", "is_default_language": 0},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1)
|
||||
|
||||
def test_invalid_jinja_template_in_body_text_raises(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Invalid Body Template",
|
||||
dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
|
||||
|
||||
def test_invalid_jinja_template_in_closing_text_raises(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Invalid Closing Template",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"}
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
|
||||
|
||||
def test_valid_jinja_template_passes(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Valid Template",
|
||||
dunning_letter_text=[
|
||||
{
|
||||
"language": "en",
|
||||
"body_text": "Outstanding amount is {{ outstanding_amount }}",
|
||||
"closing_text": "Regards, {{ company }}",
|
||||
}
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_set_default_dunning_type_unsets_previous_default(self):
|
||||
first = make_dunning_type("_Test Dunning Default One", is_default=1)
|
||||
first.insert()
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1)
|
||||
|
||||
second = make_dunning_type("_Test Dunning Default Two", is_default=1)
|
||||
second.insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0)
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1)
|
||||
|
||||
def test_set_default_dunning_type_scoped_per_company(self):
|
||||
company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1)
|
||||
company_1.insert()
|
||||
|
||||
company_2 = make_dunning_type(
|
||||
"_Test Dunning Default Co2",
|
||||
company="_Test Company 1",
|
||||
is_default=1,
|
||||
)
|
||||
company_2.insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1)
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1)
|
||||
|
||||
@@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus == 1) {
|
||||
frappe.call({
|
||||
method: "check_journal_entry_condition",
|
||||
method: "check_journal_and_reversal",
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
if (!r.message.journals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
} else if (!r.message.reversals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Reversal Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_reverse_journal(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
},
|
||||
});
|
||||
},
|
||||
make_reverse_journal: function (frm) {
|
||||
frappe.call({
|
||||
method: "make_reverse_journal",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Reversing Journals..."),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Exchange Rate Revaluation Account", {
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Criterion, Order
|
||||
from frappe.query_builder.functions import NullIf, Sum
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
|
||||
@@ -90,25 +90,31 @@ class ExchangeRateRevaluation(Document):
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = "GL Entry"
|
||||
self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_journal_entry_condition(self):
|
||||
def check_journal_and_reversal(self):
|
||||
exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
|
||||
|
||||
journals_posted = False
|
||||
reversals_posted = False
|
||||
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(jea)
|
||||
.select(jea.parent)
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run()
|
||||
.run(pluck="name")
|
||||
)
|
||||
|
||||
if journals:
|
||||
gle = qb.DocType("GL Entry")
|
||||
total_amt = (
|
||||
@@ -123,12 +129,31 @@ class ExchangeRateRevaluation(Document):
|
||||
.run()
|
||||
)
|
||||
|
||||
if total_amt and total_amt[0][0] != self.total_gain_loss:
|
||||
return True
|
||||
if total_amt and total_amt[0][0] == self.total_gain_loss:
|
||||
journals_posted = True
|
||||
else:
|
||||
return False
|
||||
journals_posted = False
|
||||
|
||||
return True
|
||||
# reverse journals
|
||||
reverse_journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.notnull())
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
if reverse_journals:
|
||||
reversals_posted = True
|
||||
else:
|
||||
reversals_posted = False
|
||||
|
||||
return {"journals_posted": journals_posted, "reversals_posted": reversals_posted}
|
||||
|
||||
def fetch_and_calculate_accounts_data(self):
|
||||
accounts = self.get_accounts_data()
|
||||
@@ -342,6 +367,7 @@ class ExchangeRateRevaluation(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_jv_entries(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
zero_balance_jv = self.make_jv_for_zero_balance()
|
||||
if zero_balance_jv:
|
||||
frappe.msgprint(
|
||||
@@ -571,6 +597,38 @@ class ExchangeRateRevaluation(Document):
|
||||
journal_entry.save()
|
||||
return journal_entry
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
if journals:
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.submit()
|
||||
frappe.msgprint(
|
||||
_("Revaluation journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
"""
|
||||
|
||||
@@ -130,7 +130,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
@@ -213,7 +214,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
@@ -287,3 +289,83 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
for key, _val in expected_data.items():
|
||||
self.assertEqual(expected_data.get(key), account_details.get(key))
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
|
||||
)
|
||||
def test_05_revaluation_journal_reversal(self):
|
||||
"""
|
||||
Test reversing of revaluation journals
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debtors_usd,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_submit=1,
|
||||
)
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 80
|
||||
si.save().submit()
|
||||
|
||||
err = frappe.new_doc("Exchange Rate Revaluation")
|
||||
err.company = self.company
|
||||
err.posting_date = today()
|
||||
err.fetch_and_calculate_accounts_data()
|
||||
self.assertEqual(len(err.accounts), 1)
|
||||
err.save().submit()
|
||||
|
||||
gain_loss_account = err.get_for_unrealized_gain_loss_account()
|
||||
usd_account = err.accounts[0].account
|
||||
old_balance = err.accounts[0].balance_in_base_currency
|
||||
new_balance = err.accounts[0].new_balance_in_base_currency
|
||||
total_gain_loss = err.total_gain_loss
|
||||
|
||||
# Create JV for ERR
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
|
||||
je = je.submit()
|
||||
|
||||
je.reload()
|
||||
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
|
||||
self.assertEqual(len(je.accounts), 3)
|
||||
expected = [
|
||||
(usd_account, new_balance, 0.0, 100.0, 0.0),
|
||||
(usd_account, 0.0, old_balance, 0.0, 100.0),
|
||||
(gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss),
|
||||
]
|
||||
actual = []
|
||||
for acc in je.accounts:
|
||||
actual.append(
|
||||
(
|
||||
acc.account,
|
||||
acc.debit,
|
||||
acc.credit,
|
||||
acc.debit_in_account_currency,
|
||||
acc.credit_in_account_currency,
|
||||
)
|
||||
)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
# Assert reversals are not posted
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertFalse(ret.get("reversals_posted"))
|
||||
|
||||
err.make_reverse_journal()
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertTrue(ret.get("reversals_posted"))
|
||||
|
||||
reverse_jv = frappe.db.get_all(
|
||||
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
|
||||
)
|
||||
self.assertIsNotNone(reverse_jv)
|
||||
|
||||
@@ -40,6 +40,10 @@ frappe.ui.form.on("Journal Entry", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
|
||||
frm.set_read_only();
|
||||
}
|
||||
|
||||
erpnext.toggle_naming_series();
|
||||
|
||||
if (frm.doc.docstatus > 0) {
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
|
||||
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
|
||||
get_party_tax_withholding_details,
|
||||
)
|
||||
from erpnext.accounts.general_ledger import validate_opening_entry_against_pcv
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
@@ -123,6 +124,9 @@ class JournalEntry(AccountsController):
|
||||
if not self.is_opening:
|
||||
self.is_opening = "No"
|
||||
|
||||
if self.is_opening == "Yes":
|
||||
validate_opening_entry_against_pcv(self.company)
|
||||
|
||||
self.clearance_date = None
|
||||
|
||||
self.validate_party()
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
frappe.listview_settings["Journal Entry"] = {
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"],
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark", "reversal_of"],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.docstatus == 0) {
|
||||
return [__("Draft", "red", "docstatus,=,0")];
|
||||
} else if (doc.docstatus == 2) {
|
||||
return [__("Cancelled", "grey", "docstatus,=,2")];
|
||||
} else {
|
||||
return [__(doc.voucher_type), "blue", "voucher_type,=," + doc.voucher_type];
|
||||
} else if (doc.docstatus === 1) {
|
||||
if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") {
|
||||
return [__("Reversal Of Exchange Rate Revaluation"), "blue"];
|
||||
}
|
||||
return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,15 +24,22 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
setTimeout(
|
||||
() => {
|
||||
frm.doc.import_in_progress = false;
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
frm.page.clear_indicator();
|
||||
frm.dashboard.hide_progress();
|
||||
|
||||
if (frm.doc.invoice_type == "Sales") {
|
||||
frappe.msgprint(__("Opening Sales Invoices have been created."));
|
||||
if (!data.errors) {
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
const message =
|
||||
frm.doc.invoice_type == "Sales"
|
||||
? __("Opening Sales Invoice(s) have been created.")
|
||||
: __("Opening Purchase Invoice(s) have been created.");
|
||||
frappe.show_alert({
|
||||
message: message,
|
||||
indicator: "green",
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint(__("Opening Purchase Invoices have been created."));
|
||||
frm.refresh_fields();
|
||||
}
|
||||
},
|
||||
1500,
|
||||
@@ -75,29 +82,31 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
setup_company_filters: function (frm) {
|
||||
frm.set_query("cost_center", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", "invoices", { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "project", "invoices");
|
||||
frm.events.apply_company_query_filter(frm, "project");
|
||||
frm.events.apply_company_query_filter(frm, "cost_center", undefined, { is_group: 0 });
|
||||
frm.events.apply_company_query_filter(frm, "temporary_opening_account", "invoices", {
|
||||
account_type: "Temporary",
|
||||
is_group: 0,
|
||||
});
|
||||
},
|
||||
|
||||
frm.set_query("cost_center", function (doc) {
|
||||
apply_company_query_filter: function (frm, field_name, child_doctype = null, filters = {}) {
|
||||
const query = function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
...filters,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
frm.set_query("temporary_opening_account", "invoices", function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (child_doctype) {
|
||||
frm.set_query(field_name, child_doctype, query);
|
||||
} else {
|
||||
frm.set_query(field_name, query);
|
||||
}
|
||||
},
|
||||
|
||||
company: function (frm) {
|
||||
@@ -121,10 +130,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
},
|
||||
|
||||
invoice_type: function (frm) {
|
||||
$.each(frm.doc.invoices, (idx, row) => {
|
||||
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
|
||||
row.party = "";
|
||||
});
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
},
|
||||
|
||||
@@ -165,7 +171,19 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Opening Invoice Creation Tool Item", {
|
||||
invoices_add: (frm) => {
|
||||
invoices_add: (frm, cdt, cdn) => {
|
||||
const row = frappe.get_doc(cdt, cdn);
|
||||
const field_copy = [];
|
||||
|
||||
["project", "cost_center"].forEach((fieldname) => {
|
||||
if (frm.doc[fieldname]) {
|
||||
frappe.model.set_value(cdt, cdn, fieldname, frm.doc[fieldname]);
|
||||
} else {
|
||||
field_copy.push(fieldname);
|
||||
}
|
||||
});
|
||||
|
||||
frm.script_manager.copy_from_first_row("invoices", row, field_copy);
|
||||
frm.trigger("update_invoice_table");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,6 +122,17 @@ class OpeningInvoiceCreationTool(Document):
|
||||
if not row.get(scrub(d)):
|
||||
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
|
||||
|
||||
self.validate_temporary_opening_account(row)
|
||||
|
||||
def validate_temporary_opening_account(self, row):
|
||||
account_type = frappe.get_cached_value("Account", row.temporary_opening_account, "account_type")
|
||||
if account_type != "Temporary":
|
||||
frappe.throw(
|
||||
_("Row #{0}: {1} account is not of type {2}").format(
|
||||
row.idx, row.temporary_opening_account, "Temporary"
|
||||
)
|
||||
)
|
||||
|
||||
def get_invoices(self):
|
||||
invoices = []
|
||||
for row in self.invoices:
|
||||
@@ -191,6 +202,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
"description": row.item_name or "Opening Invoice Item",
|
||||
income_expense_account_field: row.temporary_opening_account,
|
||||
"cost_center": cost_center,
|
||||
"project": row.get("project") or self.get("project"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -254,22 +266,32 @@ class OpeningInvoiceCreationTool(Document):
|
||||
def start_import(invoices):
|
||||
errors = 0
|
||||
names = []
|
||||
total = len(invoices)
|
||||
for idx, d in enumerate(invoices):
|
||||
# Scope each invoice to a savepoint so a failure only undoes that invoice.
|
||||
# A plain rollback() would discard the whole transaction — including invoices
|
||||
# imported earlier in this batch and the error logs of earlier failures (the
|
||||
# latter only survive on mariadb because the Error Log table is MyISAM; on
|
||||
# postgres they would be lost). Rolling back to a savepoint keeps both.
|
||||
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
|
||||
frappe.db.savepoint(savepoint)
|
||||
is_last = idx == total - 1
|
||||
try:
|
||||
invoice_number = None
|
||||
if d.invoice_number:
|
||||
invoice_number = d.invoice_number
|
||||
publish(idx, len(invoices), d.doctype)
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
doc.submit()
|
||||
frappe.db.commit()
|
||||
names.append(doc.name)
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
except Exception:
|
||||
errors += 1
|
||||
frappe.db.rollback()
|
||||
doc.log_error("Opening invoice creation failed")
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
if errors:
|
||||
frappe.msgprint(
|
||||
_("You had {} errors while creating opening invoices. Check {} for more details").format(
|
||||
@@ -281,7 +303,7 @@ def start_import(invoices):
|
||||
return names
|
||||
|
||||
|
||||
def publish(index, total, doctype):
|
||||
def publish(index, total, doctype, errors=None):
|
||||
frappe.publish_realtime(
|
||||
"opening_invoice_creation_progress",
|
||||
dict(
|
||||
@@ -289,6 +311,7 @@ def publish(index, total, doctype):
|
||||
message=_("Creating {} out of {} {}").format(index + 1, total, doctype),
|
||||
count=index + 1,
|
||||
total=total,
|
||||
errors=errors,
|
||||
),
|
||||
user=frappe.session.user,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
|
||||
create_dimension,
|
||||
@@ -11,6 +12,7 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp
|
||||
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
|
||||
get_temporary_opening_account,
|
||||
)
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
|
||||
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
|
||||
|
||||
@@ -27,21 +29,26 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
|
||||
self,
|
||||
invoice_type="Sales",
|
||||
company=None,
|
||||
party_1=None,
|
||||
party_2=None,
|
||||
invoice_number=None,
|
||||
invoices=None,
|
||||
project=None,
|
||||
cost_center=None,
|
||||
department=None,
|
||||
return_doc=False,
|
||||
):
|
||||
doc = frappe.get_single("Opening Invoice Creation Tool")
|
||||
args = get_opening_invoice_creation_dict(
|
||||
invoice_type=invoice_type,
|
||||
company=company,
|
||||
party_1=party_1,
|
||||
party_2=party_2,
|
||||
invoice_number=invoice_number,
|
||||
invoices=invoices,
|
||||
project=project,
|
||||
cost_center=cost_center,
|
||||
department=department,
|
||||
)
|
||||
doc.update(args)
|
||||
|
||||
if return_doc:
|
||||
return doc
|
||||
|
||||
return doc.make_invoices()
|
||||
|
||||
def test_opening_sales_invoice_creation(self):
|
||||
@@ -50,8 +57,8 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status"],
|
||||
0: ["_Test Customer", 300, "Overdue"],
|
||||
1: ["_Test Customer 1", 250, "Overdue"],
|
||||
0: ["_Test Customer", 200, "Overdue"],
|
||||
1: ["_Test Customer 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value)
|
||||
|
||||
@@ -68,48 +75,34 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
|
||||
for field_idx, field in enumerate(expected_value["keys"]):
|
||||
self.assertEqual(si.get(field, ""), expected_value[invoice_idx][field_idx])
|
||||
|
||||
def test_opening_invoice_requires_temporary_account_type(self):
|
||||
doc = self.make_invoices(company="_Test Opening Invoice Company", return_doc=True)
|
||||
doc.invoices[0].temporary_opening_account = "Sales - _TOIC"
|
||||
self.assertRaises(frappe.ValidationError, doc.make_invoices)
|
||||
|
||||
def test_opening_purchase_invoice_creation(self):
|
||||
invoices = self.make_invoices(invoice_type="Purchase", company="_Test Opening Invoice Company")
|
||||
|
||||
self.assertEqual(len(invoices), 2)
|
||||
expected_value = {
|
||||
"keys": ["supplier", "outstanding_amount", "status"],
|
||||
0: ["_Test Supplier", 300, "Overdue"],
|
||||
1: ["_Test Supplier 1", 250, "Overdue"],
|
||||
0: ["_Test Supplier", 200, "Overdue"],
|
||||
1: ["_Test Supplier 1", 200, "Overdue"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, "Purchase")
|
||||
|
||||
def test_opening_sales_invoice_creation_with_missing_debit_account(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
|
||||
old_default_receivable_account = frappe.db.get_value("Company", company, "default_receivable_account")
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", "")
|
||||
old_default_receivable_account = frappe.db.get_value(
|
||||
"Company", "_Test Opening Invoice Company", "default_receivable_account"
|
||||
)
|
||||
frappe.db.set_value("Company", "_Test Opening Invoice Company", "default_receivable_account", "")
|
||||
|
||||
if not frappe.db.exists("Cost Center", "_Test Opening Invoice Company - _TOIC"):
|
||||
cc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Opening Invoice Company",
|
||||
"is_group": 1,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
}
|
||||
)
|
||||
cc.insert(ignore_mandatory=True)
|
||||
cc2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "Main",
|
||||
"is_group": 0,
|
||||
"company": "_Test Opening Invoice Company",
|
||||
"parent_cost_center": cc.name,
|
||||
}
|
||||
)
|
||||
cc2.insert()
|
||||
|
||||
frappe.db.set_value("Company", company, "cost_center", "Main - _TOIC")
|
||||
|
||||
self.make_invoices(company="_Test Opening Invoice Company", party_1=party_1, party_2=party_2)
|
||||
self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[{"party": party_1}, {"party": party_2}],
|
||||
)
|
||||
|
||||
# Check if missing debit account error raised
|
||||
error_log = frappe.db.exists(
|
||||
@@ -119,74 +112,113 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
|
||||
self.assertTrue(error_log)
|
||||
|
||||
# teardown
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
|
||||
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
self.make_invoices(
|
||||
company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11"
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
"_Test Opening Invoice Company",
|
||||
"default_receivable_account",
|
||||
old_default_receivable_account,
|
||||
)
|
||||
|
||||
sales_inv1 = frappe.get_all("Sales Invoice", filters={"customer": "Customer A"})[0].get("name")
|
||||
sales_inv2 = frappe.get_all("Sales Invoice", filters={"customer": "Customer B"})[0].get("name")
|
||||
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
inv_num = f"TEST-NEW-INV-{frappe.generate_hash(length=8)}"
|
||||
invoices = self.make_invoices(
|
||||
company="_Test Opening Invoice Company",
|
||||
invoices=[
|
||||
{"party": party_1, "invoice_number": inv_num},
|
||||
{"party": party_2},
|
||||
],
|
||||
)
|
||||
|
||||
# teardown
|
||||
for inv in [sales_inv1, sales_inv2]:
|
||||
doc = frappe.get_doc("Sales Invoice", inv)
|
||||
doc.cancel()
|
||||
self.assertEqual(invoices[0], inv_num)
|
||||
|
||||
def test_opening_invoice_with_accounting_dimension(self):
|
||||
invoices = self.make_invoices(
|
||||
invoice_type="Sales", company="_Test Opening Invoice Company", department="Sales - _TOIC"
|
||||
)
|
||||
|
||||
expected_value = {
|
||||
"keys": ["customer", "outstanding_amount", "status", "department"],
|
||||
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
|
||||
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
|
||||
}
|
||||
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
|
||||
for invoice in invoices:
|
||||
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
|
||||
|
||||
def tearDown(self):
|
||||
def test_opening_entry_project_linking(self):
|
||||
doc = self.make_invoices(
|
||||
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True
|
||||
)
|
||||
project_1 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 01", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
project_2 = make_project(
|
||||
{"project_name": "Test Opening Invoice projecty 02", "company": "_Test Opening Invoice Company"}
|
||||
)
|
||||
doc.invoices[0].project = project_1.name
|
||||
doc.invoices[1].project = project_2.name
|
||||
invoices = doc.make_invoices()
|
||||
sales_invoice_1 = frappe.get_doc("Sales Invoice", invoices[0])
|
||||
sales_invoice_2 = frappe.get_doc("Sales Invoice", invoices[1])
|
||||
|
||||
self.assertEqual(sales_invoice_1.items[0].project, project_1.name)
|
||||
self.assertEqual(sales_invoice_2.items[0].project, project_2.name)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
disable_dimension()
|
||||
super().tearDownClass()
|
||||
|
||||
|
||||
def get_opening_invoice_creation_dict(**args):
|
||||
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
|
||||
company = args.get("company", "_Test Company")
|
||||
default_invoices = []
|
||||
default_invoice_rows = [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 200,
|
||||
"party": f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": add_days(today(), -10),
|
||||
"posting_date": add_days(today(), -15),
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
},
|
||||
]
|
||||
|
||||
for row in args.get("invoices") or default_invoice_rows:
|
||||
default_invoices.append(
|
||||
{
|
||||
"qty": row.get("qty") or 1.0,
|
||||
"outstanding_amount": row.get("outstanding_amount") or 200,
|
||||
"party": row.get("party") or f"_Test {party}",
|
||||
"item_name": row.get("item_name") or "Opening Item",
|
||||
"due_date": row.get("due_date") or add_days(today(), -10),
|
||||
"posting_date": row.get("posting_date") or add_days(today(), -15),
|
||||
"temporary_opening_account": row.get("temporary_opening_account")
|
||||
or get_temporary_opening_account(company),
|
||||
"invoice_number": row.get("invoice_number"),
|
||||
"project": row.get("project"),
|
||||
"cost_center": row.get("cost_center"),
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict = frappe._dict(
|
||||
{
|
||||
"company": company,
|
||||
"invoice_type": args.get("invoice_type", "Sales"),
|
||||
"invoices": [
|
||||
{
|
||||
"qty": 1.0,
|
||||
"outstanding_amount": 300,
|
||||
"party": args.get("party_1") or f"_Test {party}",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": args.get("invoice_number"),
|
||||
},
|
||||
{
|
||||
"qty": 2.0,
|
||||
"outstanding_amount": 250,
|
||||
"party": args.get("party_2") or f"_Test {party} 1",
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": None,
|
||||
},
|
||||
],
|
||||
"project": args.get("project"),
|
||||
"cost_center": args.get("cost_center"),
|
||||
"invoices": default_invoices,
|
||||
}
|
||||
)
|
||||
|
||||
invoice_dict.update(args)
|
||||
invoice_dict.invoices = default_invoices
|
||||
return invoice_dict
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"qty",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break"
|
||||
"dimension_col_break",
|
||||
"project"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -79,6 +80,7 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Outstanding Amount",
|
||||
"options": "Company:company:default_currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -111,11 +113,17 @@
|
||||
"fieldname": "invoice_number",
|
||||
"fieldtype": "Data",
|
||||
"label": "Invoice Number"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-03-21 19:31:45.382656",
|
||||
"modified": "2026-07-03 15:17:11.938499",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
@@ -126,4 +134,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class OpeningInvoiceCreationToolItem(Document):
|
||||
party: DF.DynamicLink
|
||||
party_type: DF.Link | None
|
||||
posting_date: DF.Date | None
|
||||
project: DF.Link | None
|
||||
qty: DF.Data | None
|
||||
temporary_opening_account: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -2691,6 +2691,9 @@ def get_party_details(company, party_type, party, date, cost_center=None):
|
||||
if not frappe.db.exists(party_type, party):
|
||||
frappe.throw(_("{0} {1} does not exist").format(_(party_type), party))
|
||||
|
||||
ptype = "select" if frappe.only_has_select_perm(party_type) else "read"
|
||||
frappe.has_permission(party_type, ptype, party, throw=True)
|
||||
|
||||
party_account = get_party_account(party_type, party, company)
|
||||
account_currency = get_account_currency(party_account)
|
||||
account_balance = (
|
||||
@@ -2707,7 +2710,7 @@ def get_party_details(company, party_type, party, date, cost_center=None):
|
||||
)
|
||||
if party_type in ["Customer", "Supplier"]:
|
||||
party_bank_account = get_party_bank_account(party_type, party)
|
||||
bank_account = get_default_company_bank_account(company, party_type, party)
|
||||
bank_account = get_default_company_bank_account(company, party_type, party, ignore_permissions=False)
|
||||
|
||||
return {
|
||||
"party_account": party_account,
|
||||
@@ -2808,9 +2811,7 @@ 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(
|
||||
|
||||
@@ -796,10 +796,17 @@ class PaymentReconciliation(Document):
|
||||
|
||||
|
||||
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
|
||||
allocated_amount_precision = get_field_precision(
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
|
||||
)
|
||||
for inv in dr_cr_notes:
|
||||
if (
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
< inv.allocated_amount
|
||||
flt(
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
- inv.allocated_amount,
|
||||
allocated_amount_precision,
|
||||
)
|
||||
< 0
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)
|
||||
|
||||
@@ -155,6 +155,7 @@ class TestPaymentReconciliation(FrappeTestCase):
|
||||
sinv = create_sales_invoice(
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
posting_date=posting_date,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
item_code=self.item,
|
||||
@@ -2146,7 +2147,7 @@ class TestPaymentReconciliation(FrappeTestCase):
|
||||
pr.reconcile()
|
||||
|
||||
si.reload()
|
||||
self.assertEqual(si.status, "Partly Paid")
|
||||
self.assertEqual(si.status, "Overdue")
|
||||
# check PR tool output post reconciliation
|
||||
self.assertEqual(len(pr.get("invoices")), 1)
|
||||
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
|
||||
@@ -2540,6 +2541,76 @@ class TestPaymentReconciliation(FrappeTestCase):
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_cr_note_split_across_invoices_floating_point_precision(self):
|
||||
"""Regression: when a credit note is split across multiple invoices, floating-point
|
||||
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
|
||||
|
||||
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
|
||||
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
|
||||
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
|
||||
"""
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
|
||||
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
|
||||
# (invoices are sorted by posting_date ascending, so si_a is processed first).
|
||||
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
|
||||
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
|
||||
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
|
||||
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
|
||||
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
|
||||
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
|
||||
|
||||
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
|
||||
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
|
||||
# by subtracting a decimal-valued payment from the integer total:
|
||||
# 100 - 91.55 = 8.45
|
||||
# 200 - 109.28 = 90.72
|
||||
# 100 - 27.43 = 72.57
|
||||
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
|
||||
pe = get_payment_entry(si.doctype, si.name)
|
||||
pe.paid_amount = partial_paid
|
||||
pe.received_amount = partial_paid
|
||||
pe.references[0].allocated_amount = partial_paid
|
||||
pe.save().submit()
|
||||
|
||||
cr_note = self.create_sales_invoice(
|
||||
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
|
||||
)
|
||||
cr_note.is_return = 1
|
||||
cr_note = cr_note.save().submit()
|
||||
|
||||
pr = self.create_payment_reconciliation()
|
||||
# Widen date range so all three invoices (oldest is -2 days) are fetched
|
||||
pr.from_invoice_date = add_days(nowdate(), -2)
|
||||
pr.to_invoice_date = nowdate()
|
||||
pr.from_payment_date = nowdate()
|
||||
pr.to_payment_date = nowdate()
|
||||
|
||||
pr.get_unreconciled_entries()
|
||||
self.assertEqual(len(pr.invoices), 3)
|
||||
self.assertEqual(len(pr.payments), 1)
|
||||
|
||||
invoices = [x.as_dict() for x in pr.invoices]
|
||||
payments = [x.as_dict() for x in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
|
||||
self.assertEqual(len(pr.allocation), 3)
|
||||
last_row = pr.allocation[-1]
|
||||
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
|
||||
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
|
||||
|
||||
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
|
||||
pr.reconcile()
|
||||
|
||||
si_a.reload()
|
||||
si_b.reload()
|
||||
si_c.reload()
|
||||
self.assertEqual(si_a.outstanding_amount, 0)
|
||||
self.assertEqual(si_b.outstanding_amount, 0)
|
||||
# si_c is only partially settled: 72.57 - 50.83 = 21.74
|
||||
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
|
||||
|
||||
|
||||
def make_customer(customer_name, currency=None):
|
||||
if not frappe.db.exists("Customer", customer_name):
|
||||
|
||||
@@ -367,6 +367,7 @@ class PaymentRequest(Document):
|
||||
bank_amount=bank_amount,
|
||||
created_from_payment_request=True,
|
||||
)
|
||||
payment_entry.set_missing_ref_details(force=True)
|
||||
|
||||
payment_entry.update(
|
||||
{
|
||||
|
||||
@@ -618,6 +618,22 @@ class TestPaymentRequest(FrappeTestCase):
|
||||
pi.load_from_db()
|
||||
self.assertEqual(pr_2.grand_total, pi.outstanding_amount)
|
||||
|
||||
def test_payment_entry_reference_details_fetched_from_invoice(self):
|
||||
pi = make_purchase_invoice(currency="INR", qty=1, rate=94500)
|
||||
pi.submit()
|
||||
|
||||
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1)
|
||||
pr.grand_total = 94000
|
||||
pr.submit()
|
||||
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
|
||||
self.assertEqual(pe.references[0].reference_name, pi.name)
|
||||
self.assertEqual(pe.references[0].total_amount, pi.grand_total)
|
||||
self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount)
|
||||
self.assertEqual(pe.references[0].allocated_amount, 94000)
|
||||
self.assertEqual(pe.paid_amount, 94000)
|
||||
|
||||
def test_consider_journal_entry_and_return_invoice(self):
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
|
||||
@@ -379,12 +379,15 @@ class TestPeriodClosingVoucher(unittest.TestCase):
|
||||
|
||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||
|
||||
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
posting_date="2022-01-01",
|
||||
)
|
||||
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", "2021-12-31")
|
||||
|
||||
try:
|
||||
make_reverse_gl_entries(
|
||||
voucher_type="Journal Entry",
|
||||
voucher_no=jv.name,
|
||||
)
|
||||
finally:
|
||||
frappe.db.set_single_value("Accounts Settings", "acc_frozen_upto", None)
|
||||
|
||||
totals_after_cancel = frappe.db.sql(
|
||||
"""
|
||||
|
||||
@@ -156,6 +156,24 @@ class PricingRule(Document):
|
||||
if len(values) != len(set(values)):
|
||||
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
|
||||
|
||||
if self.apply_on == "Item Code":
|
||||
self.validate_template_with_variant(values)
|
||||
|
||||
def validate_template_with_variant(self, item_codes):
|
||||
# throws if a template and its variant both exist in one rule
|
||||
variants = frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)},
|
||||
fields=["name", "variant_of"],
|
||||
)
|
||||
if variants:
|
||||
variant = variants[0]
|
||||
frappe.throw(
|
||||
_("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format(
|
||||
frappe.bold(variant.name), frappe.bold(variant.variant_of)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_mandatory(self):
|
||||
if self.has_priority and not self.priority:
|
||||
throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority"))
|
||||
|
||||
@@ -336,6 +336,31 @@ class TestPricingRule(FrappeTestCase):
|
||||
details = get_item_details(args)
|
||||
self.assertEqual(details.get("discount_percentage"), 17.5)
|
||||
|
||||
def test_pricing_rule_with_template_and_its_variant(self):
|
||||
if not frappe.db.exists("Item", "Test Variant PRT"):
|
||||
variant = frappe.new_doc("Item")
|
||||
variant.item_code = "Test Variant PRT"
|
||||
variant.item_name = "Test Variant PRT"
|
||||
variant.item_group = "_Test Item Group"
|
||||
variant.is_stock_item = 1
|
||||
variant.variant_of = "_Test Variant Item"
|
||||
variant.stock_uom = "_Test UOM"
|
||||
variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"})
|
||||
variant.insert()
|
||||
|
||||
rule = frappe.new_doc("Pricing Rule")
|
||||
rule.title = "_Test Pricing Rule Template Variant"
|
||||
rule.apply_on = "Item Code"
|
||||
rule.currency = "USD"
|
||||
rule.selling = 1
|
||||
rule.rate_or_discount = "Discount Percentage"
|
||||
rule.discount_percentage = 10
|
||||
rule.company = "_Test Company"
|
||||
rule.append("items", {"item_code": "_Test Variant Item"})
|
||||
rule.append("items", {"item_code": "Test Variant PRT"})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, rule.insert)
|
||||
|
||||
def test_pricing_rule_for_stock_qty(self):
|
||||
test_record = {
|
||||
"doctype": "Pricing Rule",
|
||||
|
||||
@@ -86,50 +86,55 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
cancel_pcv_processing(self.name)
|
||||
|
||||
|
||||
def initialize_parallel_threads(docname: str):
|
||||
threads = 4
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(threads)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
x.name,
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=x.name,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(4)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": x.processing_date,
|
||||
"parent": docname,
|
||||
"report_type": x.report_type,
|
||||
"parentfield": x.parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
initialize_parallel_threads(docname)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -247,11 +252,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(1)
|
||||
@@ -261,15 +266,15 @@ def schedule_next_date(docname: str):
|
||||
if not is_scheduler_inactive():
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": to_process[0].processing_date,
|
||||
"parent": docname,
|
||||
"report_type": to_process[0].report_type,
|
||||
"parentfield": to_process[0].parentfield,
|
||||
},
|
||||
to_process[0].name,
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
@@ -277,6 +282,7 @@ def schedule_next_date(docname: str):
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=to_process[0].name,
|
||||
date=to_process[0].processing_date,
|
||||
report_type=to_process[0].report_type,
|
||||
parentfield=to_process[0].parentfield,
|
||||
@@ -441,6 +447,11 @@ def summarize_and_post_ledger_entries(docname):
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
@@ -526,10 +537,10 @@ def build_dimension_wise_balance_dict(gl_entries):
|
||||
return dimension_balances
|
||||
|
||||
|
||||
def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
|
||||
current_date_status = frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"status",
|
||||
)
|
||||
if current_date_status != "Running":
|
||||
@@ -576,17 +587,20 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
# save results
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"closing_balance",
|
||||
frappe.json.dumps(res),
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
row_name,
|
||||
"status",
|
||||
"Completed",
|
||||
)
|
||||
# commit heavy computation before touching PPCV or PPCVD
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
# chain call
|
||||
schedule_next_date(docname)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index(
|
||||
"Process Period Closing Voucher Detail",
|
||||
["parent", "status", "parentfield", "idx", "processing_date"],
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
|
||||
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
|
||||
<div>
|
||||
{% if filters.party[0] == filters.party_name[0] %}
|
||||
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>
|
||||
|
||||
@@ -97,9 +97,9 @@ class ProcessStatementOfAccounts(Document):
|
||||
if not self.pdf_name:
|
||||
self.pdf_name = "{{ customer.customer_name }}"
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
validate_template(self.subject, restrict_globals=True)
|
||||
validate_template(self.body, restrict_globals=True)
|
||||
validate_template(self.pdf_name, restrict_globals=True)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -501,15 +501,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)
|
||||
filename = frappe.render_template(doc.pdf_name, context, restrict_globals=True)
|
||||
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)
|
||||
message = frappe.render_template(doc.body, context)
|
||||
subject = frappe.render_template(doc.subject, context, restrict_globals=True)
|
||||
message = frappe.render_template(doc.body, context, restrict_globals=True)
|
||||
|
||||
if doc.sender:
|
||||
sender_email = frappe.db.get_value("Email Account", doc.sender, "email_id")
|
||||
|
||||
@@ -1397,8 +1397,10 @@
|
||||
"fetch_from": "supplier.represents_company",
|
||||
"fieldname": "represents_company",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Represents Company",
|
||||
"options": "Company"
|
||||
"options": "Company",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.update_stock && doc.is_internal_supplier",
|
||||
@@ -1660,7 +1662,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-17 20:44:00.221219",
|
||||
"modified": "2026-07-12 23:54:21.263951",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -2884,8 +2884,6 @@ 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)
|
||||
@@ -2894,14 +2892,8 @@ 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
|
||||
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")
|
||||
target.language = source.language
|
||||
target.get_dunning_letter_text()
|
||||
|
||||
# update outstanding from doc
|
||||
if source.payment_schedule and len(source.payment_schedule) == 1:
|
||||
|
||||
@@ -697,13 +697,15 @@ def make_reverse_gl_entries(
|
||||
partial_cancel=partial_cancel,
|
||||
)
|
||||
validate_accounting_period(gl_entries)
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
if immutable_ledger_enabled:
|
||||
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
else:
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
|
||||
check_freezing_date(validation_date, adv_adj)
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
if partial_cancel:
|
||||
@@ -770,7 +772,7 @@ def make_reverse_gl_entries(
|
||||
|
||||
if immutable_ledger_enabled:
|
||||
new_gle["is_cancelled"] = 0
|
||||
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
|
||||
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
|
||||
elif posting_date:
|
||||
new_gle["posting_date"] = posting_date
|
||||
|
||||
@@ -802,13 +804,24 @@ def check_freezing_date(posting_date, adv_adj=False):
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
def validate_opening_entry_against_pcv(company):
|
||||
if frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
|
||||
frappe.throw(
|
||||
_("Opening Entry can not be created after Period Closing Voucher is created."),
|
||||
_(
|
||||
"A Period Closing Voucher is already submitted and an Opening Entry can no longer be created. {0} to learn more."
|
||||
).format(
|
||||
'<a href="https://docs.frappe.io/erpnext/period-closing-voucher#14-pcv-and-opening-entries" target="_blank" rel="noopener">'
|
||||
+ _("Read the docs")
|
||||
+ "</a>"
|
||||
),
|
||||
title=_("Invalid Opening Entry"),
|
||||
)
|
||||
|
||||
|
||||
def validate_against_pcv(is_opening, posting_date, company):
|
||||
if is_opening:
|
||||
validate_opening_entry_against_pcv(company)
|
||||
|
||||
# Local import so you don't have to touch file-level imports
|
||||
from frappe.query_builder.functions import Max
|
||||
|
||||
|
||||
@@ -431,6 +431,17 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
|
||||
Will first search in party (Customer / Supplier) record, if not found,
|
||||
will search in group (Customer Group / Supplier Group),
|
||||
finally will return default."""
|
||||
|
||||
def account_perm_check(account):
|
||||
ptype = "select" if frappe.only_has_select_perm("Account") else "read"
|
||||
if frappe.has_permission("Account", ptype, account):
|
||||
return
|
||||
|
||||
# Using custom message to prevent data leak in case of `apply_strict_permission` is enabled.
|
||||
frappe.throw(
|
||||
_("User don't have permissions to select/read this account."), exc=frappe.PermissionError
|
||||
)
|
||||
|
||||
if not party_type:
|
||||
frappe.throw(_("Party Type is mandatory"))
|
||||
if not company:
|
||||
@@ -441,46 +452,51 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
|
||||
return frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
account = frappe.db.get_value(
|
||||
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
|
||||
)
|
||||
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
|
||||
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
else:
|
||||
account = frappe.db.get_value(
|
||||
"Party Account",
|
||||
{"parenttype": party_group_doctype, "parent": group, "company": company},
|
||||
"account",
|
||||
"Party Account", {"parenttype": party_type, "parent": party, "company": company}, "account"
|
||||
)
|
||||
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
default_account_name = (
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
party_group_doctype = "Customer Group" if party_type == "Customer" else "Supplier Group"
|
||||
group = frappe.get_cached_value(party_type, party, scrub(party_group_doctype))
|
||||
account = frappe.db.get_value(
|
||||
"Party Account",
|
||||
{"parenttype": party_group_doctype, "parent": group, "company": company},
|
||||
"account",
|
||||
)
|
||||
|
||||
existing_gle_currency = get_party_gle_currency(party_type, party, company)
|
||||
if existing_gle_currency:
|
||||
if account:
|
||||
account_currency = frappe.get_cached_value("Account", account, "account_currency")
|
||||
if (account and account_currency != existing_gle_currency) or not account:
|
||||
account = get_party_gle_account(party_type, party, company)
|
||||
if not account and party_type in ["Customer", "Supplier"]:
|
||||
default_account_name = (
|
||||
"default_receivable_account" if party_type == "Customer" else "default_payable_account"
|
||||
)
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
# get default account on the basis of party type
|
||||
if not account:
|
||||
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
|
||||
default_account_name = "default_" + account_type.lower() + "_account"
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
existing_gle_currency = get_party_gle_currency(party_type, party, company)
|
||||
if existing_gle_currency:
|
||||
if account:
|
||||
account_currency = frappe.get_cached_value("Account", account, "account_currency")
|
||||
if (account and account_currency != existing_gle_currency) or not account:
|
||||
account = get_party_gle_account(party_type, party, company)
|
||||
|
||||
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
|
||||
# get default account on the basis of party type
|
||||
if not account:
|
||||
account_type = frappe.get_cached_value("Party Type", party_type, "account_type")
|
||||
default_account_name = "default_" + account_type.lower() + "_account"
|
||||
account = frappe.get_cached_value("Company", company, default_account_name)
|
||||
|
||||
if account:
|
||||
account_perm_check(account)
|
||||
|
||||
if include_advance and party and party_type in ["Customer", "Supplier", "Student"]:
|
||||
advance_account = get_party_advance_account(party_type, party, company)
|
||||
|
||||
if advance_account:
|
||||
account_perm_check(advance_account)
|
||||
return [account, advance_account]
|
||||
else:
|
||||
return [account]
|
||||
|
||||
return [account]
|
||||
|
||||
return account
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"doctype": "Report",
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:09:46.150861",
|
||||
"modified": "2026-07-01 13:37:41.185347",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Payable",
|
||||
@@ -15,6 +15,11 @@
|
||||
"ref_doctype": "Purchase Invoice",
|
||||
"report_name": "Accounts Payable",
|
||||
"report_type": "Script Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
@@ -28,5 +33,7 @@
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"doctype": "Report",
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-03-06 05:52:06.235584",
|
||||
"modified": "2026-07-01 13:37:44.167999",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Receivable",
|
||||
@@ -15,6 +15,11 @@
|
||||
"ref_doctype": "Sales Invoice",
|
||||
"report_name": "Accounts Receivable",
|
||||
"report_type": "Script Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "Payment Ledger Entry"
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
@@ -22,5 +27,7 @@
|
||||
{
|
||||
"role": "Accounts User"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -263,10 +263,12 @@ class ReceivablePayableReport:
|
||||
|
||||
# Build and use a separate row for Employee Advances.
|
||||
# This allows Payments or Journals made against Emp Advance to be processed.
|
||||
if (
|
||||
not row
|
||||
and ple.against_voucher_type == "Employee Advance"
|
||||
and self.filters.handle_employee_advances
|
||||
if not row and (
|
||||
(ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances)
|
||||
or (
|
||||
ple.against_voucher_type == "Exchange Rate Revaluation"
|
||||
and self.filters.for_revaluation_journals
|
||||
)
|
||||
):
|
||||
_d = self.build_voucher_dict(ple)
|
||||
_d.voucher_type = ple.against_voucher_type
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2018-09-07 12:18:21.850851",
|
||||
"modified": "2026-06-22 13:38:25.236839",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Balance Sheet",
|
||||
@@ -15,6 +15,11 @@
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Balance Sheet",
|
||||
"report_type": "Script Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
@@ -25,5 +30,7 @@
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"synced_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -4,14 +4,23 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt
|
||||
from frappe.utils import add_days, cint, flt
|
||||
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
accumulate_values_into_parents,
|
||||
add_total_row,
|
||||
calculate_values,
|
||||
compute_growth_view_data,
|
||||
filter_accounts,
|
||||
filter_out_zero_value_rows,
|
||||
get_accounting_entries,
|
||||
get_accounts,
|
||||
get_appropriate_currency,
|
||||
get_columns,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
get_period_list,
|
||||
prepare_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -259,3 +268,196 @@ 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)
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"idx": 3,
|
||||
"is_standard": "Yes",
|
||||
"letterhead": null,
|
||||
"modified": "2025-11-05 15:47:59.597853",
|
||||
"modified": "2026-07-01 13:36:06.682661",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "General Ledger",
|
||||
@@ -30,5 +34,6 @@
|
||||
"role": "Auditor"
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -792,3 +792,288 @@ 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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:12:40.282376",
|
||||
"modified": "2026-07-01 13:36:14.934965",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Profit and Loss Statement",
|
||||
@@ -15,6 +15,12 @@
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Profit and Loss Statement",
|
||||
"report_type": "Script Report",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
@@ -25,5 +31,7 @@
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -7,12 +7,20 @@ from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.report.financial_statements import (
|
||||
accumulate_values_into_parents,
|
||||
add_total_row,
|
||||
calculate_values,
|
||||
compute_growth_view_data,
|
||||
compute_margin_view_data,
|
||||
filter_accounts,
|
||||
filter_out_zero_value_rows,
|
||||
get_accounts,
|
||||
get_appropriate_currency,
|
||||
get_columns,
|
||||
get_data,
|
||||
get_filtered_list_for_consolidated_report,
|
||||
get_period_list,
|
||||
prepare_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -193,3 +201,125 @@ 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)
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
"doctype": "Report",
|
||||
"idx": 2,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2017-02-24 20:12:33.520866",
|
||||
"modified": "2026-07-01 17:32:21.801141",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Trial Balance",
|
||||
"owner": "Administrator",
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Trial Balance",
|
||||
"doctype_to_sync": [
|
||||
{
|
||||
"doc_type": "GL Entry"
|
||||
}
|
||||
],
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
@@ -25,5 +30,7 @@
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"snapshot_report": 0,
|
||||
"timeout": 0
|
||||
}
|
||||
|
||||
@@ -510,3 +510,215 @@ 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
|
||||
|
||||
@@ -74,6 +74,15 @@ frappe.ui.form.on("Asset Repair", {
|
||||
};
|
||||
};
|
||||
}
|
||||
if (frm.doc.asset) {
|
||||
frappe.db.get_value("Asset", frm.doc.asset, "status").then(({ message }) => {
|
||||
frm.set_df_property(
|
||||
"capitalize_repair_cost",
|
||||
"read_only",
|
||||
message && message.status === "Fully Depreciated"
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
repair_status: (frm) => {
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Asset",
|
||||
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Fully Depreciated\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
|
||||
"link_filters": "[[\"Asset\",\"status\",\"not in\",[\"Work In Progress\",\"Capitalized\",\"Sold\",\"Scrapped\",\"Cancelled\",null]]]",
|
||||
"options": "Asset",
|
||||
"reqd": 1
|
||||
},
|
||||
@@ -251,7 +251,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-06 15:48:13.862505",
|
||||
"modified": "2026-07-14 10:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Repair",
|
||||
|
||||
@@ -77,12 +77,15 @@ class AssetRepair(AccountsController):
|
||||
)
|
||||
|
||||
def validate_asset(self):
|
||||
if self.asset_doc.status in ("Sold", "Fully Depreciated", "Scrapped"):
|
||||
if self.asset_doc.status in ("Sold", "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)):
|
||||
|
||||
@@ -16,7 +16,10 @@ 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
|
||||
from erpnext.controllers.website_list_for_contact import (
|
||||
add_role_for_portal_user,
|
||||
link_portal_users_to_contacts,
|
||||
)
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
|
||||
|
||||
@@ -103,6 +106,7 @@ 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:
|
||||
|
||||
@@ -204,7 +204,28 @@ class TestSupplierPortal(FrappeTestCase):
|
||||
supplier.append("portal_users", {"user": user})
|
||||
supplier.save()
|
||||
|
||||
frappe.set_user(user)
|
||||
_, suppliers = get_customers_suppliers("Purchase Order", user)
|
||||
with self.set_user(user):
|
||||
_, suppliers = get_customers_suppliers("Purchase Order", user)
|
||||
|
||||
self.assertIn(supplier.name, suppliers)
|
||||
|
||||
def test_portal_user_contact_link(self):
|
||||
user_email = frappe.generate_hash() + "@example.com"
|
||||
user = frappe.new_doc("User")
|
||||
user.email = user_email
|
||||
user.first_name = "Test Portal Contact User"
|
||||
user.send_welcome_email = False
|
||||
user.insert(ignore_permissions=True)
|
||||
|
||||
contact = frappe.new_doc("Contact")
|
||||
contact.first_name = "Test Portal Contact User"
|
||||
contact.add_email(user_email, is_primary=1)
|
||||
contact.links = []
|
||||
contact.insert(ignore_permissions=True)
|
||||
|
||||
supplier = create_supplier()
|
||||
supplier.append("portal_users", {"user": user.name})
|
||||
supplier.save()
|
||||
|
||||
contact.reload()
|
||||
self.assertTrue(contact.has_link("Supplier", supplier.name))
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"fieldname": "net_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -613,7 +614,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-17 12:05:52.441645",
|
||||
"modified": "2026-07-15 10:33:24.855979",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -141,6 +141,26 @@ class AccountsController(TransactionBase):
|
||||
if self.doctype in relevant_docs:
|
||||
self.set_payment_schedule()
|
||||
|
||||
def before_insert(self):
|
||||
self.clear_clearance_date_on_amend()
|
||||
|
||||
def clear_clearance_date_on_amend(self):
|
||||
"""Drop the bank reconciliation clearance date copied over while amending.
|
||||
|
||||
The framework copies `no_copy` fields when amending, so a reconciled
|
||||
voucher would carry a stale clearance date into its amendment even though
|
||||
the linked bank transaction gets unreconciled on cancellation.
|
||||
"""
|
||||
if not self.get("amended_from"):
|
||||
return
|
||||
|
||||
if self.meta.has_field("clearance_date"):
|
||||
self.clearance_date = None
|
||||
|
||||
for payment in self.get("payments") or []:
|
||||
if payment.meta.has_field("clearance_date"):
|
||||
payment.clearance_date = None
|
||||
|
||||
def remove_bundle_for_non_stock_invoices(self):
|
||||
has_sabb = False
|
||||
if self.doctype in ("Sales Invoice", "Purchase Invoice") and not self.update_stock:
|
||||
|
||||
@@ -177,6 +177,68 @@ def update_variant_attribute_values(item_attribute):
|
||||
frappe.flags.attribute_values = None
|
||||
|
||||
|
||||
def get_attribute_abbr_renames(item_attribute):
|
||||
"""Return the set of (current) attribute values whose abbreviation was renamed."""
|
||||
if item_attribute.numeric_values:
|
||||
return set()
|
||||
|
||||
db_value = item_attribute.get_doc_before_save()
|
||||
if not db_value:
|
||||
return set()
|
||||
|
||||
old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values}
|
||||
changed_values = set()
|
||||
|
||||
for row in item_attribute.item_attribute_values:
|
||||
if row.name in old_abbrs and old_abbrs[row.name] != row.abbr:
|
||||
changed_values.add(row.attribute_value)
|
||||
|
||||
return changed_values
|
||||
|
||||
|
||||
def update_variant_item_codes_for_abbr_renames(item_attribute):
|
||||
"""Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation."""
|
||||
changed_values = get_attribute_abbr_renames(item_attribute)
|
||||
if not changed_values:
|
||||
return
|
||||
|
||||
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
|
||||
variant_names = (
|
||||
frappe.qb.from_(item_variant_table)
|
||||
.select(item_variant_table.parent)
|
||||
.where(item_variant_table.attribute == item_attribute.name)
|
||||
.where(item_variant_table.attribute_value.isin(list(changed_values)))
|
||||
.distinct()
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
for variant_name in variant_names:
|
||||
rename_variant_item_code(variant_name)
|
||||
|
||||
|
||||
def rename_variant_item_code(variant_name):
|
||||
"""Recompute a variant's item_code/item_name from its template and current attribute abbreviations,
|
||||
renaming the Item if it has changed."""
|
||||
variant = frappe.get_doc("Item", variant_name)
|
||||
if not variant.variant_of:
|
||||
return
|
||||
|
||||
template = frappe.get_cached_doc("Item", variant.variant_of)
|
||||
|
||||
new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes})
|
||||
make_variant_item_code(template.item_code, template.item_name, new_code)
|
||||
|
||||
if not new_code.item_code or new_code.item_code == variant.item_code:
|
||||
return
|
||||
|
||||
frappe.rename_doc("Item", variant.item_code, new_code.item_code)
|
||||
|
||||
# Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so
|
||||
# item_name is always rebuilt here too, even if it had since been customized away from that pattern.
|
||||
if new_code.item_name and new_code.item_name != variant.item_name:
|
||||
frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name)
|
||||
|
||||
|
||||
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
|
||||
allow_rename_attribute_value = frappe.db.get_single_value(
|
||||
"Item Variant Settings", "allow_rename_attribute_value"
|
||||
|
||||
@@ -158,7 +158,8 @@ status_map = {
|
||||
"Pick List": [
|
||||
["Draft", None],
|
||||
["Open", "eval:self.docstatus == 1"],
|
||||
["Completed", "stock_entry_exists"],
|
||||
["Completed", "is_fully_transferred"],
|
||||
["Partially Transferred", "is_partially_transferred"],
|
||||
[
|
||||
"Partly Delivered",
|
||||
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",
|
||||
|
||||
@@ -263,6 +263,10 @@ class StockController(AccountsController):
|
||||
parent_details = self.get_parent_details_for_packed_items()
|
||||
|
||||
for row in self.get(table_name):
|
||||
item_code = row.get("rm_item_code") or row.get("item_code")
|
||||
if not item_code or not self.is_serial_batch_item(item_code):
|
||||
continue
|
||||
|
||||
if (
|
||||
not via_landed_cost_voucher
|
||||
and row.serial_and_batch_bundle
|
||||
@@ -1509,9 +1513,10 @@ class StockController(AccountsController):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_accounting_ledger_preview(company, doctype, docname):
|
||||
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
||||
filters = frappe._dict(company=company, include_dimensions=1)
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_gl_preview")
|
||||
|
||||
gl_columns, gl_data = get_accounting_ledger_preview(doc, filters)
|
||||
@@ -1522,9 +1527,10 @@ def show_accounting_ledger_preview(company, doctype, docname):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_stock_ledger_preview(company, doctype, docname):
|
||||
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
||||
filters = frappe._dict(company=company)
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_sl_preview")
|
||||
|
||||
sl_columns, sl_data = get_stock_ledger_preview(doc, filters)
|
||||
|
||||
@@ -361,13 +361,24 @@ def based_wise_columns_query(based_on, trans):
|
||||
|
||||
# based_on_cols, based_on_select, based_on_group_by, addl_tables
|
||||
if based_on == "Item":
|
||||
based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
|
||||
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.item_code, t2.item_name,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_code"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Item Group":
|
||||
based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Item Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Item Group",
|
||||
"width": 120,
|
||||
"fieldname": "item_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.item_group,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_group"
|
||||
based_on_details["addl_tables"] = ""
|
||||
@@ -375,32 +386,80 @@ def based_wise_columns_query(based_on, trans):
|
||||
elif based_on == "Customer":
|
||||
if trans == "Quotation":
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Party:Link/Customer:120",
|
||||
"Party Name:Data:120",
|
||||
"Territory:Link/Territory:120",
|
||||
{
|
||||
"label": _("Party"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"width": 120,
|
||||
"fieldname": "party",
|
||||
},
|
||||
{"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"},
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.party_name, t1.customer_name, t1.territory,"
|
||||
else:
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Customer:Link/Customer:120",
|
||||
"Customer Name:Data:120",
|
||||
"Territory:Link/Territory:120",
|
||||
{
|
||||
"label": _("Customer"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"width": 120,
|
||||
"fieldname": "customer",
|
||||
},
|
||||
{
|
||||
"label": _("Customer Name"),
|
||||
"fieldtype": "Data",
|
||||
"width": 120,
|
||||
"fieldname": "customer_name",
|
||||
},
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory,"
|
||||
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Customer Group":
|
||||
based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Customer Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer Group",
|
||||
"fieldname": "customer_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.customer_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.customer_group"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Supplier":
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Supplier:Link/Supplier:120",
|
||||
"Supplier Name:Data:120",
|
||||
"Supplier Group:Link/Supplier Group:140",
|
||||
{
|
||||
"label": _("Supplier"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier",
|
||||
"width": 120,
|
||||
"fieldname": "supplier",
|
||||
},
|
||||
{"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"},
|
||||
{
|
||||
"label": _("Supplier Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier Group",
|
||||
"width": 140,
|
||||
"fieldname": "supplier_group",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.supplier"
|
||||
@@ -408,26 +467,58 @@ def based_wise_columns_query(based_on, trans):
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
elif based_on == "Supplier Group":
|
||||
based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Supplier Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier Group",
|
||||
"width": 140,
|
||||
"fieldname": "supplier_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t3.supplier_group"
|
||||
based_on_details["addl_tables"] = ",`tabSupplier` t3"
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
elif based_on == "Territory":
|
||||
based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.territory,"
|
||||
based_on_details["based_on_group_by"] = "t1.territory"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Project":
|
||||
if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]:
|
||||
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Project",
|
||||
"width": 120,
|
||||
"fieldname": "project",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.project,"
|
||||
based_on_details["based_on_group_by"] = "t1.project"
|
||||
based_on_details["addl_tables"] = ""
|
||||
elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]:
|
||||
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Project",
|
||||
"width": 120,
|
||||
"fieldname": "project",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.project,"
|
||||
based_on_details["based_on_group_by"] = "t2.project"
|
||||
based_on_details["addl_tables"] = ""
|
||||
@@ -435,7 +526,15 @@ def based_wise_columns_query(based_on, trans):
|
||||
frappe.throw(_("Project-wise data is not available for Quotation"))
|
||||
|
||||
based_on_details["based_on_select"] += "t4.default_currency as currency,"
|
||||
based_on_details["based_on_cols"].append("Currency:Link/Currency:120")
|
||||
based_on_details["based_on_cols"].append(
|
||||
{
|
||||
"label": _("Currency"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Currency",
|
||||
"width": 120,
|
||||
"fieldname": "currency",
|
||||
}
|
||||
)
|
||||
based_on_details["addl_tables"] += ", `tabCompany` t4"
|
||||
based_on_details["addl_tables_relational_cond"] = (
|
||||
based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name"
|
||||
@@ -446,6 +545,14 @@ def based_wise_columns_query(based_on, trans):
|
||||
|
||||
def group_wise_column(group_by):
|
||||
if group_by:
|
||||
return [group_by + ":Link/" + group_by + ":120"]
|
||||
return [
|
||||
{
|
||||
"label": _(group_by),
|
||||
"fieldtype": "Link",
|
||||
"options": group_by,
|
||||
"width": 120,
|
||||
"fieldname": frappe.scrub(group_by),
|
||||
}
|
||||
]
|
||||
else:
|
||||
return []
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
|
||||
@@ -306,3 +308,63 @@ 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)
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Settings", {
|
||||
// refresh: function(frm) {
|
||||
// }
|
||||
refresh: function (frm) {
|
||||
const flag = frm.events.calculate_visiblity_flag(frm);
|
||||
|
||||
frm.set_df_property("allowed_users", "hidden", !flag);
|
||||
frm.set_df_property("allowed_users", "reqd", flag);
|
||||
},
|
||||
|
||||
enable_frappe_crm_data_synchronization: function (frm) {
|
||||
const flag = frm.events.calculate_visiblity_flag(frm);
|
||||
|
||||
if (flag) {
|
||||
frappe.show_alert(
|
||||
__("Allowed Users is required for data synchronization from remote Frappe CRM site.")
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization
|
||||
is set and crm app is not installed.
|
||||
*/
|
||||
|
||||
frm.set_df_property("allowed_users", "hidden", !flag);
|
||||
frm.set_df_property("allowed_users", "reqd", flag);
|
||||
},
|
||||
|
||||
calculate_visiblity_flag: function (frm) {
|
||||
const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization;
|
||||
const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm"));
|
||||
|
||||
return crm_sync_enabled && !is_crm_installed;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,9 +120,9 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
|
||||
"fieldname": "allowed_users",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"hidden": 1,
|
||||
"label": "Allowed Users",
|
||||
"options": "Frappe CRM Allowed User",
|
||||
"permlevel": 1
|
||||
@@ -139,7 +139,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-22 01:26:13.474915",
|
||||
"modified": "2026-07-01 01:09:16.461470",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM Settings",
|
||||
|
||||
@@ -6,6 +6,8 @@ from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
from erpnext.crm.frappe_crm_api import is_crm_installed
|
||||
|
||||
|
||||
class CRMSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -46,13 +48,16 @@ class CRMSettings(Document):
|
||||
)
|
||||
|
||||
def validate_allowed_users(self):
|
||||
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
|
||||
if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
)
|
||||
)
|
||||
|
||||
if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users:
|
||||
frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site."))
|
||||
|
||||
def before_save(self):
|
||||
self.clear_allowed_users()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
|
||||
import click
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
@@ -150,7 +151,9 @@ def create_customer(customer_data=None):
|
||||
for field in CUSTOMER_ALLOWED_FIELDS:
|
||||
if customer_data.get(field) is not None:
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
|
||||
# If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records.
|
||||
customer.insert(ignore_permissions=not is_crm_installed())
|
||||
customer_name = customer.name
|
||||
|
||||
contacts = json.loads(customer_data.get("contacts"))
|
||||
@@ -169,6 +172,10 @@ def validate_frappe_crm_sync():
|
||||
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
|
||||
)
|
||||
|
||||
# Skip allowed_users validation if CRM is installed on the site.
|
||||
if is_crm_installed():
|
||||
return
|
||||
|
||||
allowed_users = [d.user for d in CRMSettings.allowed_users]
|
||||
|
||||
if frappe.session.user not in allowed_users:
|
||||
@@ -178,3 +185,35 @@ def validate_frappe_crm_sync():
|
||||
),
|
||||
exc=frappe.PermissionError,
|
||||
)
|
||||
|
||||
|
||||
def is_crm_installed():
|
||||
return "crm" in frappe.get_installed_apps()
|
||||
|
||||
|
||||
def remove_allowed_users_on_crm_install():
|
||||
try:
|
||||
CRMSettings = frappe.get_single("CRM Settings")
|
||||
|
||||
if not CRMSettings.enable_frappe_crm_data_synchronization:
|
||||
return
|
||||
|
||||
CRMSettings.allowed_users = []
|
||||
CRMSettings.save()
|
||||
click.secho("Removed 'Allowed Users' from CRM Settings.")
|
||||
except Exception:
|
||||
click.secho("'Allowed Users' from CRM Settings couldn't be cleared.")
|
||||
|
||||
|
||||
def disable_frappe_crm_data_synchronization_on_crm_uninstall():
|
||||
try:
|
||||
CRMSettings = frappe.get_single("CRM Settings")
|
||||
|
||||
if not CRMSettings.enable_frappe_crm_data_synchronization:
|
||||
return
|
||||
|
||||
CRMSettings.enable_frappe_crm_data_synchronization = 0
|
||||
CRMSettings.save()
|
||||
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.")
|
||||
except Exception:
|
||||
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.")
|
||||
|
||||
@@ -166,6 +166,7 @@ def get_open_todos(ref_doctype, ref_docname):
|
||||
"allocated_to",
|
||||
"date",
|
||||
],
|
||||
order_by="date asc",
|
||||
)
|
||||
|
||||
|
||||
@@ -190,6 +191,7 @@ def get_open_events(ref_doctype, ref_docname):
|
||||
& (event_link.reference_docname == ref_docname)
|
||||
& (event.status == "Open")
|
||||
)
|
||||
.orderby(event.starts_on)
|
||||
)
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ before_install = [
|
||||
]
|
||||
after_install = "erpnext.setup.install.after_install"
|
||||
|
||||
after_app_install = "erpnext.setup.install.after_app_install"
|
||||
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
|
||||
|
||||
boot_session = "erpnext.startup.boot.boot_session"
|
||||
notification_config = "erpnext.startup.notifications.get_notification_config"
|
||||
get_help_messages = "erpnext.utilities.activation.get_help_messages"
|
||||
|
||||
@@ -441,7 +441,11 @@ frappe.ui.form.on("BOM", {
|
||||
},
|
||||
|
||||
routing(frm) {
|
||||
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
|
||||
// Refetch operations whenever the routing is (re)selected, so that
|
||||
// changing the routing - e.g. on a new BOM version copied from another
|
||||
// BOM - replaces the operations with those of the newly selected routing
|
||||
// instead of keeping the old ones.
|
||||
if (frm.doc.routing && frm.doc.with_operations) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "get_routing",
|
||||
|
||||
@@ -347,6 +347,49 @@ class TestJobCard(FrappeTestCase):
|
||||
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
|
||||
|
||||
@@ -365,8 +365,6 @@ 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 {
|
||||
|
||||
@@ -1708,7 +1708,13 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
if (not 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)
|
||||
get_materials_from_other_locations(
|
||||
item,
|
||||
warehouses,
|
||||
new_mr_items,
|
||||
company,
|
||||
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
|
||||
)
|
||||
|
||||
mr_items = new_mr_items
|
||||
|
||||
@@ -1728,7 +1734,9 @@ 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):
|
||||
def get_materials_from_other_locations(
|
||||
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False
|
||||
):
|
||||
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
|
||||
|
||||
stock_uom, purchase_uom = frappe.db.get_value(
|
||||
@@ -1773,7 +1781,8 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
if flt(required_qty, precision) > 0:
|
||||
required_qty = required_qty
|
||||
if consider_minimum_order_qty:
|
||||
required_qty = max(required_qty, flt(item.get("min_order_qty")))
|
||||
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
@@ -1832,6 +1832,17 @@ class TestProductionPlan(FrappeTestCase):
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -687,6 +687,28 @@ class TestWorkOrder(FrappeTestCase):
|
||||
ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
|
||||
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
|
||||
|
||||
@change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
|
||||
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
|
||||
# "_Test Item Group" is master data with buying_cost_center already set to
|
||||
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
|
||||
# BOM need to be created, since no existing item in that group has one.
|
||||
fg_item = make_item(
|
||||
"_Test FG Item For Item Group Cost Center",
|
||||
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
|
||||
)
|
||||
|
||||
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
|
||||
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
|
||||
|
||||
wo_order = make_wo_order_test_record(
|
||||
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
|
||||
)
|
||||
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
|
||||
ste.insert()
|
||||
|
||||
fg_row = next(d for d in ste.items if d.is_finished_item)
|
||||
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
|
||||
|
||||
def test_operation_time_with_batch_size(self):
|
||||
fg_item = "Test Batch Size Item For BOM"
|
||||
rm1 = "Test Batch Size Item RM 1 For BOM"
|
||||
@@ -1523,6 +1545,38 @@ class TestWorkOrder(FrappeTestCase):
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
def test_status_in_process_when_only_one_required_item_transferred(self):
|
||||
"""Stock Entry created from a Pick List that picked only one of the required items:
|
||||
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
|
||||
still move to In Process because material is already in WIP."""
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list
|
||||
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
|
||||
)
|
||||
|
||||
pick_list = create_pick_list(work_order.name, for_qty=work_order.qty)
|
||||
# pick only _Test Item; the other required item is left out of this pick list
|
||||
pick_list.pick_manually = 1
|
||||
pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"]
|
||||
pick_list.save()
|
||||
pick_list.submit()
|
||||
|
||||
stock_entry = frappe.get_doc(create_stock_entry(frappe.as_json(pick_list.as_dict())))
|
||||
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
|
||||
stock_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
|
||||
self.assertEqual(work_order.status, "In Process")
|
||||
|
||||
def test_backflushed_batch_raw_materials_based_on_transferred(self):
|
||||
frappe.db.set_single_value(
|
||||
"Manufacturing Settings",
|
||||
|
||||
@@ -157,6 +157,7 @@ class WorkOrder(Document):
|
||||
self.check_wip_warehouse_skip()
|
||||
self.calculate_operating_cost()
|
||||
self.validate_qty()
|
||||
self.validate_dates()
|
||||
self.validate_transfer_against()
|
||||
self.validate_operations()
|
||||
self.status = self.get_status()
|
||||
@@ -175,6 +176,11 @@ class WorkOrder(Document):
|
||||
|
||||
self.validate_operations_sequence()
|
||||
|
||||
def validate_dates(self):
|
||||
if self.planned_start_date and self.planned_end_date:
|
||||
if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date):
|
||||
frappe.throw(_("Planned End Date cannot be before Planned Start Date"))
|
||||
|
||||
def validate_operations_sequence(self):
|
||||
if all([not op.sequence_id for op in self.operations]):
|
||||
for op in self.operations:
|
||||
@@ -406,7 +412,11 @@ class WorkOrder(Document):
|
||||
elif self.docstatus == 1:
|
||||
if status not in ["Closed", "Stopped"]:
|
||||
status = "Not Started"
|
||||
if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer:
|
||||
if (
|
||||
flt(self.material_transferred_for_manufacturing) > 0
|
||||
or self.skip_transfer
|
||||
or self.has_transferred_material()
|
||||
):
|
||||
status = "In Process"
|
||||
|
||||
precision = frappe.get_precision("Work Order", "produced_qty")
|
||||
@@ -425,6 +435,26 @@ class WorkOrder(Document):
|
||||
|
||||
return status
|
||||
|
||||
def has_transferred_material(self):
|
||||
"""True if any raw material was transferred against this work order via a pick list
|
||||
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
qty = (
|
||||
frappe.qb.from_(ste)
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(Sum(ste_child.transfer_qty))
|
||||
.where(
|
||||
(ste.work_order == self.name)
|
||||
& (ste.docstatus == 1)
|
||||
& (ste.purpose == "Material Transfer for Manufacture")
|
||||
& (ste.is_return == 0)
|
||||
& (ste.pick_list.isnotnull())
|
||||
)
|
||||
).run()[0][0]
|
||||
return flt(qty) > 0
|
||||
|
||||
def update_work_order_qty(self):
|
||||
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
|
||||
based on Stock Entry"""
|
||||
@@ -1245,6 +1275,10 @@ 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("Material Transfer for Manufacture")
|
||||
|
||||
@@ -262,7 +262,6 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
|
||||
erpnext.patches.v14_0.update_proprietorship_to_individual
|
||||
erpnext.patches.v15_0.rename_subcontracting_fields
|
||||
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
|
||||
erpnext.patches.v16_0.create_company_custom_fields
|
||||
|
||||
[post_model_sync]
|
||||
erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets
|
||||
@@ -422,6 +421,7 @@ execute:frappe.db.set_single_value("Accounts Settings", "fetch_valuation_rate_fo
|
||||
erpnext.patches.v15_0.add_company_payment_gateway_account
|
||||
erpnext.patches.v15_0.update_uae_zero_rated_fetch
|
||||
erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter
|
||||
erpnext.patches.v16_0.create_company_custom_fields
|
||||
erpnext.patches.v15_0.set_asset_status_if_not_already_set
|
||||
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
|
||||
@@ -438,3 +438,8 @@ erpnext.patches.v16_0.migrate_address_contact_custom_fields
|
||||
erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item
|
||||
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
for custom_field in frappe.get_all(
|
||||
"Custom Field",
|
||||
filters={
|
||||
"fieldname": "service_level_agreement",
|
||||
"fieldtype": "Link",
|
||||
"options": "Service Level Agreement",
|
||||
"link_filters": ("is", "not set"),
|
||||
},
|
||||
fields=["name", "dt"],
|
||||
):
|
||||
link_filters = frappe.as_json(
|
||||
[["Service Level Agreement", "document_type", "=", custom_field.dt]], indent=None
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Custom Field", custom_field.name, "link_filters", link_filters, update_modified=False
|
||||
)
|
||||
frappe.clear_cache(doctype=custom_field.dt)
|
||||
@@ -0,0 +1,20 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
for docfield in frappe.get_all(
|
||||
"DocField",
|
||||
filters={
|
||||
"parenttype": "DocType",
|
||||
"fieldname": "service_level_agreement",
|
||||
"fieldtype": "Link",
|
||||
"options": "Service Level Agreement",
|
||||
"link_filters": ("is", "not set"),
|
||||
},
|
||||
fields=["name", "parent"],
|
||||
):
|
||||
link_filters = frappe.as_json(
|
||||
[["Service Level Agreement", "document_type", "=", docfield.parent]], indent=None
|
||||
)
|
||||
frappe.db.set_value("DocField", docfield.name, "link_filters", link_filters, update_modified=False)
|
||||
frappe.clear_cache(doctype=docfield.parent)
|
||||
33
erpnext/patches/v16_0/access_control_for_project_users.py
Normal file
33
erpnext/patches/v16_0/access_control_for_project_users.py
Normal file
@@ -0,0 +1,33 @@
|
||||
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
|
||||
58
erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py
Normal file
58
erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def execute():
|
||||
StockEntry = frappe.qb.DocType("Stock Entry")
|
||||
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
pick_lists = (
|
||||
frappe.qb.from_(StockEntry)
|
||||
.select(StockEntry.pick_list)
|
||||
.distinct()
|
||||
.where((StockEntry.pick_list.isnotnull()) & (StockEntry.docstatus == 1))
|
||||
).run(pluck=True)
|
||||
|
||||
if not pick_lists:
|
||||
return
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(StockEntryDetail)
|
||||
.join(StockEntry)
|
||||
.on(StockEntryDetail.parent == StockEntry.name)
|
||||
.select(
|
||||
StockEntry.pick_list,
|
||||
StockEntryDetail.item_code,
|
||||
StockEntryDetail.s_warehouse,
|
||||
Sum(StockEntryDetail.transfer_qty).as_("qty"),
|
||||
)
|
||||
.where((StockEntry.pick_list.isin(pick_lists)) & (StockEntry.docstatus == 1))
|
||||
.groupby(StockEntry.pick_list, StockEntryDetail.item_code, StockEntryDetail.s_warehouse)
|
||||
).run(as_dict=True)
|
||||
|
||||
transferred = {(r.pick_list, r.item_code, r.s_warehouse): flt(r.qty) for r in rows}
|
||||
|
||||
items = frappe.get_all(
|
||||
"Pick List Item",
|
||||
filters={"parent": ("in", pick_lists), "picked_qty": (">", 0)},
|
||||
fields=["name", "parent", "item_code", "warehouse", "picked_qty"],
|
||||
order_by="idx",
|
||||
)
|
||||
|
||||
updates = {}
|
||||
for row in items:
|
||||
key = (row.parent, row.item_code, row.warehouse)
|
||||
available = transferred.get(key, 0)
|
||||
if available <= 0:
|
||||
continue
|
||||
qty = min(flt(row.picked_qty), available)
|
||||
transferred[key] = available - qty
|
||||
updates[row.name] = {"transferred_qty": qty}
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
frappe.db.auto_commit_on_many_writes = True
|
||||
frappe.db.bulk_update("Pick List Item", updates)
|
||||
frappe.db.auto_commit_on_many_writes = False
|
||||
@@ -0,0 +1,10 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
from erpnext.crm.frappe_crm_api import is_crm_installed, remove_allowed_users_on_crm_install
|
||||
|
||||
if not is_crm_installed():
|
||||
return
|
||||
|
||||
remove_allowed_users_on_crm_install()
|
||||
@@ -83,7 +83,7 @@
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "status",
|
||||
"oldfieldtype": "Select",
|
||||
"options": "Open\nCompleted\nCancelled",
|
||||
"options": "Open\nOn hold\nCompleted\nCancelled",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
@@ -205,13 +205,15 @@
|
||||
"fieldname": "users",
|
||||
"fieldtype": "Table",
|
||||
"label": "Users",
|
||||
"options": "Project User"
|
||||
"options": "Project User",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "copied_from",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Copied From",
|
||||
"permlevel": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -464,13 +466,25 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"max_attachments": 4,
|
||||
"modified": "2026-05-22 16:45:50.762759",
|
||||
"modified": "2026-07-14 14:32:11.328347",
|
||||
"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", "Completed", "Cancelled"]
|
||||
status: DF.Literal["Open", "On hold", "Completed", "Cancelled"]
|
||||
subject: DF.Data | None
|
||||
to_time: DF.Time | None
|
||||
total_billable_amount: DF.Currency
|
||||
@@ -93,6 +93,7 @@ class Project(Document):
|
||||
def validate(self):
|
||||
if not self.is_new():
|
||||
self.copy_from_template() # nosemgrep
|
||||
self.control_access_for_project_users()
|
||||
self.send_welcome_email()
|
||||
self.update_costing()
|
||||
self.update_percent_complete()
|
||||
@@ -207,6 +208,7 @@ 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 +266,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 cancelled
|
||||
if self.status == "Cancelled":
|
||||
# don't update status if it is manually set to cancelled or on hold
|
||||
if self.status in ("Cancelled", "On hold"):
|
||||
return
|
||||
|
||||
self.status = "Completed" if self.percent_complete == 100 else "Open"
|
||||
@@ -377,6 +379,34 @@ 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,6 +4,8 @@ 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];
|
||||
}
|
||||
|
||||
@@ -227,6 +227,61 @@ class TestProject(FrappeTestCase):
|
||||
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,6 +15,11 @@ 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
|
||||
@@ -390,6 +395,9 @@ 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);
|
||||
}
|
||||
@@ -456,8 +464,9 @@ 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 || row[this.uom_field] == uom;
|
||||
const qty_in_limit = flt(row[this.qty_field]) < flt(row[this.max_qty_field]);
|
||||
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 item_scanned = row.has_item_scanned;
|
||||
|
||||
let warehouse_match = true;
|
||||
|
||||
@@ -280,19 +280,17 @@ erpnext.sales_common = {
|
||||
|
||||
set_actual_qty(doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order"];
|
||||
let sales_doctypes = ["Sales Invoice", "Delivery Note", "Sales Order", "Quotation"];
|
||||
|
||||
if (row.item_code && row.warehouse && sales_doctypes.includes(doc.doctype)) {
|
||||
frappe.call({
|
||||
return this.frm.call({
|
||||
method: "erpnext.stock.get_item_details.get_bin_details",
|
||||
child: row,
|
||||
args: {
|
||||
item_code: row.item_code,
|
||||
warehouse: row.warehouse,
|
||||
},
|
||||
callback(r) {
|
||||
if (r.message) {
|
||||
frappe.model.set_value(cdt, cdn, "actual_qty", r.message.actual_qty);
|
||||
}
|
||||
company: doc.company,
|
||||
include_child_warehouses: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ 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
|
||||
from erpnext.controllers.website_list_for_contact import (
|
||||
add_role_for_portal_user,
|
||||
link_portal_users_to_contacts,
|
||||
)
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
|
||||
|
||||
@@ -243,6 +246,8 @@ 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")
|
||||
|
||||
@@ -29,7 +29,8 @@ class TestCustomer(FrappeTestCase):
|
||||
make_test_records("Item")
|
||||
|
||||
def tearDown(self):
|
||||
set_credit_limit("_Test Customer", "_Test Company", 0)
|
||||
if frappe.db.exists("Customer", "_Test Customer"):
|
||||
set_credit_limit("_Test Customer", "_Test Company", 0)
|
||||
|
||||
def test_get_customer_group_details(self):
|
||||
doc = frappe.new_doc("Customer Group")
|
||||
@@ -386,6 +387,34 @@ class TestCustomer(FrappeTestCase):
|
||||
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_group": "_Test Customer Group",
|
||||
}
|
||||
)
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Case, CustomFunction
|
||||
from frappe.query_builder.functions import Count, Max, Sum
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -24,50 +26,69 @@ def execute(filters=None):
|
||||
customers = get_sales_details(doctype)
|
||||
|
||||
data = []
|
||||
for cust in customers:
|
||||
if cint(cust[8]) >= cint(days_since_last_order):
|
||||
cust.insert(7, get_last_sales_amt(cust[0], doctype))
|
||||
data.append(cust)
|
||||
for row in customers:
|
||||
if cint(row[8]) >= cint(days_since_last_order):
|
||||
row.insert(7, get_last_sales_amt(row[0], doctype))
|
||||
data.append(row)
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_sales_details(doctype):
|
||||
cond = """sum(so.base_net_total) as 'total_order_considered',
|
||||
max(so.posting_date) as 'last_order_date',
|
||||
DATEDIFF(CURRENT_DATE, max(so.posting_date)) as 'days_since_last_order' """
|
||||
if doctype == "Sales Order":
|
||||
cond = """sum(if(so.status = "Stopped",
|
||||
so.base_net_total * so.per_delivered/100,
|
||||
so.base_net_total)) as 'total_order_considered',
|
||||
max(so.transaction_date) as 'last_order_date',
|
||||
DATEDIFF(CURRENT_DATE, max(so.transaction_date)) as 'days_since_last_order'"""
|
||||
customer = frappe.qb.DocType("Customer")
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
|
||||
return frappe.db.sql(
|
||||
f"""select
|
||||
cust.name,
|
||||
cust.customer_name,
|
||||
cust.territory,
|
||||
cust.customer_group,
|
||||
count(distinct(so.name)) as 'num_of_order',
|
||||
sum(base_net_total) as 'total_order_value', {cond}
|
||||
from `tabCustomer` cust, `tab{doctype}` so
|
||||
where cust.name = so.customer and so.docstatus = 1
|
||||
group by cust.name
|
||||
order by 'days_since_last_order' desc """,
|
||||
as_list=1,
|
||||
)
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
if doctype == "Sales Order":
|
||||
total_considered = Sum(
|
||||
Case()
|
||||
.when(
|
||||
sales_doctype.status == "Stopped",
|
||||
sales_doctype.base_net_total * sales_doctype.per_delivered / 100,
|
||||
)
|
||||
.else_(sales_doctype.base_net_total)
|
||||
)
|
||||
date_col = sales_doctype.transaction_date
|
||||
else:
|
||||
total_considered = Sum(sales_doctype.base_net_total)
|
||||
date_col = sales_doctype.posting_date
|
||||
|
||||
last_order_date = Max(date_col)
|
||||
days_since_last_order = date_diff(current_date(), last_order_date)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(customer)
|
||||
.inner_join(sales_doctype)
|
||||
.on(customer.name == sales_doctype.customer)
|
||||
.select(
|
||||
customer.name,
|
||||
customer.customer_name,
|
||||
customer.territory,
|
||||
customer.customer_group,
|
||||
Count(sales_doctype.name).distinct().as_("num_of_order"),
|
||||
Sum(sales_doctype.base_net_total).as_("total_order_value"),
|
||||
total_considered.as_("total_order_considered"),
|
||||
last_order_date.as_("last_order_date"),
|
||||
days_since_last_order.as_("days_since_last_order"),
|
||||
)
|
||||
.where(sales_doctype.docstatus == 1)
|
||||
.groupby(customer.name)
|
||||
.orderby(days_since_last_order, order=frappe.qb.desc)
|
||||
).run(as_list=True)
|
||||
|
||||
|
||||
def get_last_sales_amt(customer, doctype):
|
||||
cond = "posting_date"
|
||||
if doctype == "Sales Order":
|
||||
cond = "transaction_date"
|
||||
res = frappe.db.sql(
|
||||
f"""select base_net_total from `tab{doctype}`
|
||||
where customer = %s and docstatus = 1 order by {cond} desc
|
||||
limit 1""",
|
||||
customer,
|
||||
)
|
||||
sales_doctype = frappe.qb.DocType(doctype)
|
||||
date_col = sales_doctype.transaction_date if doctype == "Sales Order" else sales_doctype.posting_date
|
||||
|
||||
res = (
|
||||
frappe.qb.from_(sales_doctype)
|
||||
.select(sales_doctype.base_net_total)
|
||||
.where((sales_doctype.customer == customer) & (sales_doctype.docstatus == 1))
|
||||
.orderby(date_col, order=frappe.qb.desc)
|
||||
.limit(1)
|
||||
).run()
|
||||
|
||||
return res and res[0][0] or 0
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import add_days, getdate, today
|
||||
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.inactive_customers.inactive_customers import execute
|
||||
|
||||
|
||||
class TestInactiveCustomers(FrappeTestCase):
|
||||
def setUp(self):
|
||||
self.customer = frappe.new_doc("Customer")
|
||||
self.customer.customer_name = "_Test Inactive Customer"
|
||||
self.customer.customer_group = "_Test Customer Group"
|
||||
self.customer.insert()
|
||||
self.last_order_date = add_days(today(), -120)
|
||||
so = make_sales_order(
|
||||
customer=self.customer.name,
|
||||
transaction_date=self.last_order_date,
|
||||
qty=5,
|
||||
rate=200,
|
||||
)
|
||||
so.submit()
|
||||
self.sales_order = so
|
||||
|
||||
def test_invalid_doctype_is_rejected(self):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
execute,
|
||||
{"doctype": "Purchase Order", "days_since_last_order": 30},
|
||||
)
|
||||
|
||||
def test_inactive_customer_is_listed_with_expected_columns(self):
|
||||
columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 30})
|
||||
|
||||
row = self.get_customer_row(data)
|
||||
self.assertIsNotNone(row, "Inactive customer should be present in the report")
|
||||
|
||||
# Column contract: the report relies on positional access.
|
||||
self.assertEqual(row[0], self.customer.name)
|
||||
self.assertEqual(row[7], 1000) # Last Order Amount inserted at index 7 (5 * 200)
|
||||
self.assertEqual(getdate(row[8]), getdate(self.last_order_date)) # Last Order Date
|
||||
self.assertGreaterEqual(row[9], 30) # Days Since Last Order
|
||||
|
||||
def test_recent_customer_is_excluded(self):
|
||||
_columns, data = execute({"doctype": "Sales Order", "days_since_last_order": 200})
|
||||
self.assertIsNone(
|
||||
self.get_customer_row(data),
|
||||
"Customer ordering within the threshold must be excluded",
|
||||
)
|
||||
|
||||
def get_customer_row(self, data):
|
||||
return next((row for row in data if row[0] == self.customer.name), None)
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.desk.reportview import build_match_conditions
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from pypika.terms import LiteralValue
|
||||
|
||||
from erpnext import get_company_currency
|
||||
|
||||
@@ -183,8 +185,22 @@ def get_entries(filters):
|
||||
.as_("contribution_amt")
|
||||
)
|
||||
|
||||
# Only pass valid document-field filters to get_query; report-specific keys such as
|
||||
# doc_type / sales_person / item_group are handled separately below.
|
||||
doc_filters = {"docstatus": 1}
|
||||
for field in ["company", "customer", "territory"]:
|
||||
if filters.get(field):
|
||||
doc_filters[field] = filters.get(field)
|
||||
|
||||
if filters.get("from_date") and filters.get("to_date"):
|
||||
doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]]
|
||||
elif filters.get("from_date"):
|
||||
doc_filters[date_field] = [">=", filters.get("from_date")]
|
||||
elif filters.get("to_date"):
|
||||
doc_filters[date_field] = ["<=", filters.get("to_date")]
|
||||
|
||||
query = (
|
||||
frappe.get_query(dt, filters=filters, ignore_permissions=False)
|
||||
frappe.get_query(dt, filters=doc_filters)
|
||||
.join(dt_item)
|
||||
.on(dt.name == dt_item.parent)
|
||||
.join(st)
|
||||
@@ -203,48 +219,34 @@ def get_entries(filters):
|
||||
contribution_amt_case,
|
||||
)
|
||||
.where(st.parenttype == doc_type)
|
||||
.where(dt.docstatus == 1)
|
||||
)
|
||||
|
||||
if filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
query = query.where(
|
||||
st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt)))
|
||||
)
|
||||
|
||||
# only resolve items when an item_group/brand filter is set; otherwise get_items
|
||||
# would return every item in the system and add a huge IN() clause on each run
|
||||
if filters.get("item_group") or filters.get("brand"):
|
||||
items = get_items(filters)
|
||||
if not items:
|
||||
# the item_group/brand filter matched nothing -> no rows
|
||||
return []
|
||||
query = query.where(dt_item.item_code.isin([d[0] for d in items]))
|
||||
|
||||
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
|
||||
|
||||
# Apply user permissions (v15: ignore_permissions is not available)
|
||||
match_conditions = build_match_conditions(doc_type)
|
||||
if match_conditions:
|
||||
query = query.where(LiteralValue(match_conditions))
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_conditions(filters, date_field):
|
||||
conditions = [""]
|
||||
values = []
|
||||
|
||||
for field in ["company", "customer", "territory"]:
|
||||
if filters.get(field):
|
||||
conditions.append(f"dt.{field}=%s")
|
||||
values.append(filters[field])
|
||||
|
||||
if filters.get("sales_person"):
|
||||
lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
|
||||
conditions.append(
|
||||
f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)"
|
||||
)
|
||||
|
||||
if filters.get("from_date"):
|
||||
conditions.append(f"dt.{date_field}>=%s")
|
||||
values.append(filters["from_date"])
|
||||
|
||||
if filters.get("to_date"):
|
||||
conditions.append(f"dt.{date_field}<=%s")
|
||||
values.append(filters["to_date"])
|
||||
|
||||
items = get_items(filters)
|
||||
if items:
|
||||
conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items)))
|
||||
values += items
|
||||
else:
|
||||
# return empty result, if no items are fetched after filtering on 'item group' and 'brand'
|
||||
conditions.append("dt_item.item_code = Null")
|
||||
|
||||
return " and ".join(conditions), values
|
||||
|
||||
|
||||
def get_items(filters):
|
||||
item = qb.DocType("Item")
|
||||
|
||||
@@ -269,8 +271,5 @@ def get_items(filters):
|
||||
|
||||
|
||||
def get_item_details():
|
||||
item_details = {}
|
||||
for d in frappe.db.sql("""SELECT `name`, `item_group`, `brand` FROM `tabItem`""", as_dict=1):
|
||||
item_details.setdefault(d.name, d)
|
||||
|
||||
return item_details
|
||||
items = frappe.get_all("Item", fields=["name", "item_group", "brand"])
|
||||
return {d.name: d for d in items}
|
||||
|
||||
@@ -330,33 +330,48 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "round_off_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Round Off Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "round_off_cost_center",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Round Off Cost Center",
|
||||
"no_copy": 1,
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "write_off_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Write Off Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "exchange_gain_loss_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Exchange Gain / Loss Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "unrealized_exchange_gain_loss_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Unrealized Exchange Gain/Loss Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -482,6 +497,7 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "expenses_included_in_valuation",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
@@ -490,15 +506,19 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "accumulated_depreciation_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Accumulated Depreciation Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "depreciation_expense_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Depreciation Expense Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
@@ -519,29 +539,39 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "disposal_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Gain/Loss Account on Asset Disposal",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "depreciation_cost_center",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Asset Depreciation Cost Center",
|
||||
"no_copy": 1,
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "capital_work_in_progress_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Capital Work In Progress Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "asset_received_but_not_billed",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Asset Received But Not Billed",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -673,15 +703,21 @@
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "unrealized_profit_loss_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Unrealized Profit / Loss Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "default_discount_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Payment Discount Account",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -723,8 +759,10 @@
|
||||
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
|
||||
"fieldname": "default_advance_received_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Advance Received Account",
|
||||
"mandatory_depends_on": "book_advance_payments_as_liability",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -733,8 +771,10 @@
|
||||
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
|
||||
"fieldname": "default_advance_paid_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Default Advance Paid Account",
|
||||
"mandatory_depends_on": "book_advance_payments_as_liability",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -814,9 +854,12 @@
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "round_off_for_opening",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Round Off for Opening",
|
||||
"no_copy": 1,
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
@@ -865,7 +908,7 @@
|
||||
"image_field": "company_logo",
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-16 16:51:27.624096",
|
||||
"modified": "2026-07-02 07:21:21.794533",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Company",
|
||||
|
||||
@@ -74,6 +74,7 @@ class Company(NestedSet):
|
||||
default_operating_cost_account: DF.Link | None
|
||||
default_payable_account: DF.Link | None
|
||||
default_provisional_account: DF.Link | None
|
||||
default_purchase_price_variance_account: DF.Link | None
|
||||
default_receivable_account: DF.Link | None
|
||||
default_sales_contact: DF.Link | None
|
||||
default_selling_terms: DF.Link | None
|
||||
|
||||
@@ -367,3 +367,19 @@ DEFAULT_ROLE_PROFILES = {
|
||||
"Purchase Manager",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def after_app_install(app_name=None):
|
||||
if app_name == "crm":
|
||||
from erpnext.crm.frappe_crm_api import remove_allowed_users_on_crm_install
|
||||
|
||||
remove_allowed_users_on_crm_install()
|
||||
|
||||
|
||||
def after_app_uninstall(app_name=None):
|
||||
if app_name == "crm":
|
||||
from erpnext.crm.frappe_crm_api import disable_frappe_crm_data_synchronization_on_crm_uninstall
|
||||
|
||||
disable_frappe_crm_data_synchronization_on_crm_uninstall()
|
||||
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
@@ -9,6 +9,7 @@ from frappe import _
|
||||
from frappe.contacts.doctype.address.address import get_company_address
|
||||
from frappe.contacts.doctype.contact.contact import get_default_contact
|
||||
from frappe.desk.notifications import clear_doctype_notifications
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.utils import get_fetch_values
|
||||
from frappe.query_builder import DocType
|
||||
@@ -441,22 +442,34 @@ class DeliveryNote(SellingController):
|
||||
frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"]))
|
||||
|
||||
def update_current_stock(self):
|
||||
if self.get("_action") and self._action != "update_after_submit":
|
||||
for d in self.get("items"):
|
||||
d.actual_qty = frappe.db.get_value(
|
||||
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
|
||||
)
|
||||
if not (self.get("_action") and self._action != "update_after_submit"):
|
||||
return
|
||||
|
||||
for d in self.get("packed_items"):
|
||||
bin_qty = frappe.db.get_value(
|
||||
"Bin",
|
||||
{"item_code": d.item_code, "warehouse": d.warehouse},
|
||||
["actual_qty", "projected_qty"],
|
||||
as_dict=True,
|
||||
)
|
||||
if bin_qty:
|
||||
d.actual_qty = flt(bin_qty.actual_qty)
|
||||
d.projected_qty = flt(bin_qty.projected_qty)
|
||||
warehouse_item_codes = {}
|
||||
for d in self.get("items") + self.get("packed_items"):
|
||||
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
|
||||
|
||||
if not warehouse_item_codes:
|
||||
return
|
||||
|
||||
bin_map = {}
|
||||
for warehouse, item_codes in warehouse_item_codes.items():
|
||||
for b in frappe.get_all(
|
||||
"Bin",
|
||||
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
|
||||
fields=["item_code", "actual_qty", "projected_qty"],
|
||||
):
|
||||
bin_map[(b.item_code, warehouse)] = b
|
||||
|
||||
for d in self.get("items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
d.actual_qty = bin_data.actual_qty if bin_data else None
|
||||
|
||||
for d in self.get("packed_items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
if bin_data:
|
||||
d.actual_qty = flt(bin_data.actual_qty)
|
||||
d.projected_qty = flt(bin_data.projected_qty)
|
||||
|
||||
def on_submit(self):
|
||||
self.validate_packed_qty()
|
||||
@@ -910,7 +923,9 @@ def get_returned_qty_map(delivery_note):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_sales_invoice(source_name, target_doc=None, args=None):
|
||||
def make_sales_invoice(
|
||||
source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
|
||||
):
|
||||
if args is None:
|
||||
args = {}
|
||||
if isinstance(args, str):
|
||||
@@ -1015,7 +1030,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
|
||||
frappe.db.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
|
||||
)
|
||||
|
||||
if not doc.is_return:
|
||||
if doc.is_return:
|
||||
# A credit note made from a return Delivery Note should roll back the billed
|
||||
# amount on the linked Sales Order too, so that per_billed stays consistent with
|
||||
# per_delivered (which the return already reset).
|
||||
doc.update_billed_amount_in_sales_order = True
|
||||
else:
|
||||
so, doctype, fieldname = doc.get_order_details()
|
||||
if (
|
||||
doc.linked_order_has_payment_terms(so, fieldname, doctype)
|
||||
|
||||
@@ -2599,6 +2599,92 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
self.assertEqual(dn.per_returned, 100)
|
||||
self.assertEqual(returned.status, "Return")
|
||||
|
||||
def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn):
|
||||
"""Given a fully billed Sales Order and a submitted Delivery Note that delivers it,
|
||||
a credit note made from the return of that Delivery Note must reset per_billed to 0
|
||||
while leaving the delivery quantities exactly as the return already set them."""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_delivered, 100)
|
||||
self.assertEqual(so.per_billed, 100)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.insert()
|
||||
return_dn.submit()
|
||||
|
||||
# the return reverses the delivery quantities
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_delivered, 0)
|
||||
self.assertEqual(so.items[0].delivered_qty, 0)
|
||||
|
||||
credit_note = make_sales_invoice(return_dn.name)
|
||||
self.assertTrue(credit_note.is_return)
|
||||
self.assertTrue(credit_note.update_billed_amount_in_sales_order)
|
||||
# A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the
|
||||
# credit note only rolls back billing and never re-reverses the delivery quantities.
|
||||
self.assertFalse(credit_note.update_stock)
|
||||
credit_note.insert()
|
||||
credit_note.submit()
|
||||
|
||||
# per_billed is reset, and the delivery state stays exactly as the return left it
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_billed, 0)
|
||||
self.assertEqual(so.per_delivered, 0)
|
||||
self.assertEqual(so.items[0].delivered_qty, 0)
|
||||
self.assertEqual(so.items[0].returned_qty, 0)
|
||||
|
||||
# Cancelling the credit note should restore the billed amount on the Sales Order.
|
||||
credit_note.cancel()
|
||||
so.load_from_db()
|
||||
self.assertEqual(so.per_billed, 100)
|
||||
|
||||
def test_sales_order_per_billed_after_credit_note_from_return_dn(self):
|
||||
# Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note.
|
||||
# The DN carries si_detail in this path.
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
|
||||
|
||||
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
|
||||
|
||||
so = make_sales_order(qty=2)
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn = make_delivery_note(si.name)
|
||||
dn.insert()
|
||||
dn.submit()
|
||||
|
||||
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
|
||||
|
||||
def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self):
|
||||
# SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note.
|
||||
# SO per_billed rolls back via the status_updater in update_prevdoc_status.
|
||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
make_delivery_note as make_dn_from_so,
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
make_sales_invoice as make_si_from_so,
|
||||
)
|
||||
|
||||
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
|
||||
|
||||
so = make_sales_order(qty=2)
|
||||
|
||||
si = make_si_from_so(so.name)
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
dn = make_dn_from_so(so.name)
|
||||
dn.insert()
|
||||
dn.submit()
|
||||
|
||||
self.assertIsNone(dn.items[0].si_detail)
|
||||
|
||||
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
|
||||
|
||||
def test_sales_return_for_product_bundle(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"ignore_user_permissions": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Variant Of",
|
||||
"link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]",
|
||||
"options": "Item",
|
||||
"read_only": 1,
|
||||
"search_index": 1,
|
||||
@@ -897,7 +898,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-03-17 20:39:05.218344",
|
||||
"modified": "2026-07-05 23:24:45.734144",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -217,6 +217,7 @@ class Item(Document):
|
||||
self.validate_item_defaults()
|
||||
self.validate_auto_reorder_enabled_in_stock_settings()
|
||||
self.cant_change()
|
||||
self.validate_serialized_change_with_bundle()
|
||||
self.validate_item_tax_net_rate_range()
|
||||
|
||||
if not self.is_new():
|
||||
@@ -1074,6 +1075,25 @@ class Item(Document):
|
||||
|
||||
frappe.throw(msg, title=_("Linked with submitted documents"))
|
||||
|
||||
def validate_serialized_change_with_bundle(self):
|
||||
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
|
||||
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
|
||||
if self.is_new() or self.has_serial_no or not self._doc_before_save:
|
||||
return
|
||||
|
||||
# Only relevant when the item was serialized before and is now being unset.
|
||||
if not self._doc_before_save.has_serial_no:
|
||||
return
|
||||
|
||||
# Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
|
||||
if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
|
||||
).format(frappe.bold(self.name)),
|
||||
title=_("Serial and Batch Bundle Exists"),
|
||||
)
|
||||
|
||||
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
|
||||
linked_doctypes = [
|
||||
"Delivery Note Item",
|
||||
|
||||
@@ -443,6 +443,100 @@ class TestItem(FrappeTestCase):
|
||||
"Large",
|
||||
)
|
||||
|
||||
def test_rename_attribute_abbr_updates_variant_item_code(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)
|
||||
|
||||
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "LRG"
|
||||
break
|
||||
|
||||
def restore_test_size_abbr():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "L"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_abbr)
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1))
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L"))
|
||||
self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG"))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"),
|
||||
"_Test Variant Item-LRG",
|
||||
)
|
||||
|
||||
def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self):
|
||||
# item_name can be derived from the template's item_name, which may differ from its
|
||||
# item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name
|
||||
# must follow the abbreviation rename the same way item_code does.
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)
|
||||
|
||||
template = frappe.get_doc("Item", "_Test Variant Item").as_dict()
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "_Test Variant Item Diff",
|
||||
"item_name": "Test Variant Friendly Name",
|
||||
"item_group": template.item_group,
|
||||
"stock_uom": template.stock_uom,
|
||||
"has_variants": 1,
|
||||
"attributes": [{"attribute": "Test Size"}],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1))
|
||||
|
||||
variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
self.assertEqual(variant.item_code, "_Test Variant Item Diff-L")
|
||||
self.assertEqual(variant.item_name, "Test Variant Friendly Name-L")
|
||||
|
||||
# even a manually customized item_name (unrelated to the auto-generated pattern) must be
|
||||
# rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep.
|
||||
frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name")
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "LRG"
|
||||
break
|
||||
|
||||
def restore_test_size_abbr():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "L"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_abbr)
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1))
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L"))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"),
|
||||
"Test Variant Friendly Name-LRG",
|
||||
)
|
||||
|
||||
def test_make_item_variant(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
@@ -966,6 +1060,47 @@ class TestItem(FrappeTestCase):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, item_doc.save)
|
||||
|
||||
def test_cannot_unset_serialized_while_bundle_exists(self):
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
make_serial_batch_bundle,
|
||||
)
|
||||
|
||||
item = make_item(
|
||||
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
|
||||
).name
|
||||
|
||||
serial_no = f"{item}-SN-01"
|
||||
frappe.get_doc(
|
||||
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
|
||||
).insert()
|
||||
|
||||
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
|
||||
bundle = make_serial_batch_bundle(
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"company": "_Test Company",
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"voucher_type": "Stock Entry",
|
||||
"serial_nos": [serial_no],
|
||||
"type_of_transaction": "Inward",
|
||||
"do_not_submit": True,
|
||||
"ignore_sabb_validation": True,
|
||||
}
|
||||
)
|
||||
|
||||
doc = frappe.get_doc("Item", item)
|
||||
doc.has_serial_no = 0
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
# Once the bundle is removed, the item can be made non-serialized.
|
||||
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
|
||||
doc = frappe.get_doc("Item", item)
|
||||
doc.has_serial_no = 0
|
||||
doc.save()
|
||||
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
|
||||
|
||||
|
||||
def set_item_variant_settings(fields):
|
||||
doc = frappe.get_doc("Item Variant Settings")
|
||||
|
||||
@@ -10,6 +10,7 @@ from frappe.utils import flt
|
||||
from erpnext.controllers.item_variant import (
|
||||
InvalidItemAttributeValueError,
|
||||
update_variant_attribute_values,
|
||||
update_variant_item_codes_for_abbr_renames,
|
||||
validate_is_incremental,
|
||||
validate_item_attribute_value,
|
||||
)
|
||||
@@ -49,6 +50,7 @@ class ItemAttribute(Document):
|
||||
|
||||
def on_update(self):
|
||||
update_variant_attribute_values(self)
|
||||
update_variant_item_codes_for_abbr_renames(self)
|
||||
self.validate_exising_items()
|
||||
self.set_enabled_disabled_in_items()
|
||||
|
||||
|
||||
@@ -297,7 +297,8 @@ frappe.ui.form.on("Pick List", {
|
||||
items_table_name: "locations",
|
||||
qty_field: "picked_qty",
|
||||
max_qty_field: "qty",
|
||||
dont_allow_new_row: true,
|
||||
demand_ref_fields: ["sales_order_item", "material_request_item", "product_bundle_item"],
|
||||
dont_allow_new_row: !frm.doc.pick_manually,
|
||||
prompt_qty: frm.doc.prompt_qty,
|
||||
serial_no_field: "serial_no",
|
||||
};
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled",
|
||||
"options": "Draft\nOpen\nPartly Delivered\nPartially Transferred\nCompleted\nCancelled",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1,
|
||||
@@ -246,7 +246,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-03 18:36:52.282355",
|
||||
"modified": "2026-07-10 11:39:13.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user