mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-07 15:37:55 +00:00
Compare commits
290 Commits
coderabbit
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66dac9b7a5 | ||
|
|
7eadf19116 | ||
|
|
bb21415a72 | ||
|
|
bc784a0e73 | ||
|
|
48c53c06ce | ||
|
|
5e8d7a949c | ||
|
|
1d0aa4d3ec | ||
|
|
f1b79e6687 | ||
|
|
da4b78491d | ||
|
|
16c6b2c39f | ||
|
|
9e28703ad9 | ||
|
|
e9c37642c8 | ||
|
|
d191b80587 | ||
|
|
280558efa2 | ||
|
|
fbb67265b2 | ||
|
|
58e4f5690e | ||
|
|
0e1b45ca26 | ||
|
|
dfe57b9fd3 | ||
|
|
19bfa9225d | ||
|
|
5ae7805429 | ||
|
|
586431ca1c | ||
|
|
61c31f0cd0 | ||
|
|
6c65c8955c | ||
|
|
9a1f551e53 | ||
|
|
b8434ecfb0 | ||
|
|
820ccba9a4 | ||
|
|
c62b66038b | ||
|
|
15d9d8b719 | ||
|
|
cf03d03033 | ||
|
|
9cc77934a6 | ||
|
|
f40c492a05 | ||
|
|
5b3d2c0d02 | ||
|
|
ce123f1a89 | ||
|
|
18ac589796 | ||
|
|
df13308663 | ||
|
|
32b3a13591 | ||
|
|
83522bf079 | ||
|
|
7c6f0e4463 | ||
|
|
4d8ec5f54c | ||
|
|
68fabb72fa | ||
|
|
b23c6e2687 | ||
|
|
8c0dbb5bf6 | ||
|
|
3b221b0078 | ||
|
|
fb0bd44a85 | ||
|
|
cb951bbb18 | ||
|
|
0942845af7 | ||
|
|
b567eac01d | ||
|
|
278963be69 | ||
|
|
c55c97a9fe | ||
|
|
a091e47bd7 | ||
|
|
358083d759 | ||
|
|
fdebf25ca7 | ||
|
|
6710a8b032 | ||
|
|
d78a626661 | ||
|
|
48ea75c452 | ||
|
|
e04353fc31 | ||
|
|
e2ac7646e6 | ||
|
|
7a3206f0bf | ||
|
|
482efee6c0 | ||
|
|
41e1f92612 | ||
|
|
39031ae8a2 | ||
|
|
e53a74f2be | ||
|
|
3a604dd249 | ||
|
|
11ce4ad102 | ||
|
|
adabb6acb3 | ||
|
|
d1e0992253 | ||
|
|
260e6fb979 | ||
|
|
a0af90f755 | ||
|
|
b0d3681593 | ||
|
|
8b1fd7469c | ||
|
|
f991420152 | ||
|
|
31374e1134 | ||
|
|
c186b1d9c8 | ||
|
|
178bc759c7 | ||
|
|
aa8e8da29f | ||
|
|
028b6790cf | ||
|
|
333169e52b | ||
|
|
17320d1062 | ||
|
|
34cee8fa9c | ||
|
|
fe66bd4dc2 | ||
|
|
9638e7ea83 | ||
|
|
80933ec66b | ||
|
|
abef84358c | ||
|
|
64e50f2e55 | ||
|
|
848f8d6b1f | ||
|
|
1562e9b828 | ||
|
|
1778e2c564 | ||
|
|
da4d6cb567 | ||
|
|
5f80857bc9 | ||
|
|
a412e0b357 | ||
|
|
f4c0611cc5 | ||
|
|
dec474ef3a | ||
|
|
f3142c4af6 | ||
|
|
0b3e40b155 | ||
|
|
e32f898dd7 | ||
|
|
5f6ed62c03 | ||
|
|
382e4fe843 | ||
|
|
f07f2cfe88 | ||
|
|
978aa9ac23 | ||
|
|
af4707d1ee | ||
|
|
76da5efaa9 | ||
|
|
5986d79643 | ||
|
|
6cc2290f6e | ||
|
|
ba9bbed038 | ||
|
|
273ff33ebf | ||
|
|
38affb0562 | ||
|
|
44840b8b80 | ||
|
|
2f19244660 | ||
|
|
890316a793 | ||
|
|
b6aec1d31a | ||
|
|
c2cd09cda7 | ||
|
|
c4136870cf | ||
|
|
c055e86e51 | ||
|
|
3bef6bf5ef | ||
|
|
9ff5425859 | ||
|
|
d0bdbfecef | ||
|
|
c287665033 | ||
|
|
ac6583574e | ||
|
|
9a4619a289 | ||
|
|
0563faca54 | ||
|
|
bc213c45aa | ||
|
|
ad2c58cf2a | ||
|
|
8ea4e2139b | ||
|
|
9331f8ce7e | ||
|
|
41cf546946 | ||
|
|
6adbe7a3fd | ||
|
|
af9742a3ee | ||
|
|
b923a6b72c | ||
|
|
09a8905b2d | ||
|
|
766d157320 | ||
|
|
9486d0d462 | ||
|
|
d7fea5d4e1 | ||
|
|
ae339f69f4 | ||
|
|
af4d14e820 | ||
|
|
db7456eea2 | ||
|
|
88acf05ba2 | ||
|
|
bf3b3d4c02 | ||
|
|
9b794d5a42 | ||
|
|
d811a368a9 | ||
|
|
719cb42c1e | ||
|
|
8d523d2cd3 | ||
|
|
3de80ec640 | ||
|
|
c9eda3c279 | ||
|
|
10a66b8e73 | ||
|
|
0f7d89f4d1 | ||
|
|
aa8a0d0a9f | ||
|
|
2fa948b070 | ||
|
|
cb84ffd972 | ||
|
|
c28f6f1856 | ||
|
|
25bf2d94e7 | ||
|
|
2db85ad883 | ||
|
|
c3723e798d | ||
|
|
f6a96e5563 | ||
|
|
2ec119e561 | ||
|
|
a8b94f2090 | ||
|
|
96cdb7d54f | ||
|
|
f9be8a46fb | ||
|
|
3c032e1f35 | ||
|
|
97a7c0162d | ||
|
|
45a7195abe | ||
|
|
c7a7cb2b90 | ||
|
|
7ad123592c | ||
|
|
09c9ac1b66 | ||
|
|
9cc48f3686 | ||
|
|
5ed48a8539 | ||
|
|
bd595602de | ||
|
|
b3fdef8d19 | ||
|
|
99148a2aba | ||
|
|
86d6facab3 | ||
|
|
8e54be7808 | ||
|
|
f15efc543b | ||
|
|
d4ef2ebd3c | ||
|
|
767e4762bb | ||
|
|
5c2bb66028 | ||
|
|
a8af04f6fc | ||
|
|
605b7c5efe | ||
|
|
a34dd9d247 | ||
|
|
6bb0bdcdca | ||
|
|
a20fd7428c | ||
|
|
8d9b83e410 | ||
|
|
e57d2b4811 | ||
|
|
f3ba59480b | ||
|
|
4e4452b73e | ||
|
|
dbbad3e6ee | ||
|
|
d16c50486a | ||
|
|
254eb5e7d6 | ||
|
|
074bdd119b | ||
|
|
3787b9fbe5 | ||
|
|
77045e45c5 | ||
|
|
a52f42c7e4 | ||
|
|
8e0227ea68 | ||
|
|
c02fb7a619 | ||
|
|
0bec419301 | ||
|
|
4f99045ca0 | ||
|
|
eb8ea4a02d | ||
|
|
74f748e1f5 | ||
|
|
7f1c11c92e | ||
|
|
8cb0632081 | ||
|
|
e8a8d3947c | ||
|
|
4568114ba8 | ||
|
|
88d8310a47 | ||
|
|
85114eda7c | ||
|
|
5757feb45a | ||
|
|
f301b36b5e | ||
|
|
198eb372e3 | ||
|
|
60470e2a5b | ||
|
|
dc718f7acb | ||
|
|
2fe5fad884 | ||
|
|
ed4c17d3a2 | ||
|
|
f194ac093c | ||
|
|
1c82f42fa8 | ||
|
|
d42aad18a7 | ||
|
|
6a03fc6ede | ||
|
|
75999a7ae4 | ||
|
|
b53b6cac52 | ||
|
|
ca12d88514 | ||
|
|
68703d6e5b | ||
|
|
8f2b3f8703 | ||
|
|
997401a16a | ||
|
|
a62b0ca196 | ||
|
|
2e5241a211 | ||
|
|
4edcc12edc | ||
|
|
cbc0f327d6 | ||
|
|
30ae3e0b59 | ||
|
|
a28d799c09 | ||
|
|
ec3cd7e09c | ||
|
|
13d4f5ec31 | ||
|
|
baf884f95c | ||
|
|
1bcfad8eb1 | ||
|
|
2767cb04fb | ||
|
|
5401843315 | ||
|
|
5e2d246931 | ||
|
|
e8f87927e1 | ||
|
|
107fa1d605 | ||
|
|
264baf34f6 | ||
|
|
66407d22fc | ||
|
|
f26ee9e546 | ||
|
|
5154fa8259 | ||
|
|
b2feb5abbe | ||
|
|
b58c4edd5d | ||
|
|
c5bbabac4b | ||
|
|
6ac76bac6e | ||
|
|
c5fdc256fa | ||
|
|
0376bba345 | ||
|
|
2e803e6494 | ||
|
|
f13022835a | ||
|
|
6e44951a96 | ||
|
|
80730908c9 | ||
|
|
66f56eea33 | ||
|
|
fab1ef5d76 | ||
|
|
1e2c56874f | ||
|
|
8f86c1b3e9 | ||
|
|
b5ad4acc41 | ||
|
|
445a255a7f | ||
|
|
af212f520d | ||
|
|
dec67eecad | ||
|
|
758553b9fc | ||
|
|
f5b75b27d7 | ||
|
|
09325aad3d | ||
|
|
cfdbeb6a1a | ||
|
|
e1fd90f731 | ||
|
|
8ee2cbf259 | ||
|
|
2a0ba84f69 | ||
|
|
0c1df30771 | ||
|
|
ff9b392024 | ||
|
|
0b84d11600 | ||
|
|
c2810ea799 | ||
|
|
00ffdee928 | ||
|
|
b9aaae6343 | ||
|
|
e6160d1b63 | ||
|
|
92fdec9b92 | ||
|
|
d344be32a0 | ||
|
|
b7c7e0746e | ||
|
|
6079bee3a3 | ||
|
|
6a66ce5a97 | ||
|
|
40b787827a | ||
|
|
50cf814b43 | ||
|
|
0f89e750a7 | ||
|
|
2de9f8f2e2 | ||
|
|
88f6d783b4 | ||
|
|
2112f36577 | ||
|
|
610877fb17 | ||
|
|
004bd59245 | ||
|
|
84e6d278c3 | ||
|
|
2b4f621c8e | ||
|
|
31434630b5 | ||
|
|
47aa006ea9 | ||
|
|
1319b28b1f | ||
|
|
cbfb14a654 | ||
|
|
608d38a172 |
@@ -45,3 +45,9 @@ d827ed21adc7b36047e247cbb0dc6388d048a7f9
|
||||
|
||||
# `frappe.flags.in_test` => `frappe.in_test`
|
||||
7a482a69985c952de0e8193c9d4e086aee65ee6d
|
||||
|
||||
# these commits actually changed something valuable
|
||||
# but they have a lot of whitespace changes that make blame noisy
|
||||
# PR: https://github.com/frappe/erpnext/pull/49816
|
||||
3ffd50c772735877b330d010c1058f623da8721d
|
||||
0e8f8677b8eb31e7834f72d1c6314d3c3f392ca6
|
||||
|
||||
@@ -70,6 +70,7 @@ frappe.treeview_settings["Account"] = {
|
||||
args: {
|
||||
accounts: accounts,
|
||||
company: cur_tree.args.company,
|
||||
include_default_fb_balances: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"column_break_4",
|
||||
"company",
|
||||
"disabled",
|
||||
"exempted_role",
|
||||
"section_break_7",
|
||||
"closed_documents"
|
||||
],
|
||||
@@ -67,10 +68,18 @@
|
||||
"label": "Closed Documents",
|
||||
"options": "Closed Document",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"description": "Role allowed to bypass period restrictions.",
|
||||
"fieldname": "exempted_role",
|
||||
"fieldtype": "Link",
|
||||
"label": "Exempted Role",
|
||||
"link_filters": "[[\"Role\",\"disabled\",\"=\",0]]",
|
||||
"options": "Role"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-10-06 15:00:15.568067",
|
||||
"modified": "2025-12-01 16:53:44.631299",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounting Period",
|
||||
|
||||
@@ -30,6 +30,7 @@ class AccountingPeriod(Document):
|
||||
company: DF.Link
|
||||
disabled: DF.Check
|
||||
end_date: DF.Date
|
||||
exempted_role: DF.Link | None
|
||||
period_name: DF.Data
|
||||
start_date: DF.Date
|
||||
# end: auto-generated types
|
||||
@@ -113,7 +114,7 @@ def validate_accounting_period_on_doc_save(doc, method=None):
|
||||
accounting_period = (
|
||||
frappe.qb.from_(ap)
|
||||
.from_(cd)
|
||||
.select(ap.name)
|
||||
.select(ap.name, ap.exempted_role)
|
||||
.where(
|
||||
(ap.name == cd.parent)
|
||||
& (ap.company == doc.company)
|
||||
@@ -126,6 +127,11 @@ def validate_accounting_period_on_doc_save(doc, method=None):
|
||||
).run(as_dict=1)
|
||||
|
||||
if accounting_period:
|
||||
if (
|
||||
accounting_period[0].get("exempted_role")
|
||||
and accounting_period[0].get("exempted_role") in frappe.get_roles()
|
||||
):
|
||||
return
|
||||
frappe.throw(
|
||||
_("You cannot create a {0} within the closed Accounting Period {1}").format(
|
||||
doc.doctype, frappe.bold(accounting_period[0]["name"])
|
||||
|
||||
@@ -152,6 +152,5 @@ class AccountsSettings(Document):
|
||||
def drop_ar_sql_procedures(self):
|
||||
from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR
|
||||
|
||||
frappe.db.sql(f"drop function if exists {InitSQLProceduresForAR.genkey_function_name}")
|
||||
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}")
|
||||
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
"column_break_3czf",
|
||||
"bank_party_name",
|
||||
"bank_party_account_number",
|
||||
"bank_party_iban"
|
||||
"bank_party_iban",
|
||||
"extended_bank_statement_section",
|
||||
"included_fee",
|
||||
"excluded_fee"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -233,12 +236,32 @@
|
||||
{
|
||||
"fieldname": "column_break_oufv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "extended_bank_statement_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Extended Bank Statement"
|
||||
},
|
||||
{
|
||||
"fieldname": "included_fee",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Included Fee",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"description": "On save, the Excluded Fee will be converted to an Included Fee.",
|
||||
"fieldname": "excluded_fee",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Excluded Fee",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-23 17:32:58.514807",
|
||||
"modified": "2025-12-07 20:49:18.600757",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Transaction",
|
||||
|
||||
@@ -32,6 +32,8 @@ class BankTransaction(Document):
|
||||
date: DF.Date | None
|
||||
deposit: DF.Currency
|
||||
description: DF.SmallText | None
|
||||
excluded_fee: DF.Currency
|
||||
included_fee: DF.Currency
|
||||
naming_series: DF.Literal["ACC-BTN-.YYYY.-"]
|
||||
party: DF.DynamicLink | None
|
||||
party_type: DF.Link | None
|
||||
@@ -45,9 +47,11 @@ class BankTransaction(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def before_validate(self):
|
||||
self.handle_excluded_fee()
|
||||
self.update_allocated_amount()
|
||||
|
||||
def validate(self):
|
||||
self.validate_included_fee()
|
||||
self.validate_duplicate_references()
|
||||
self.validate_currency()
|
||||
|
||||
@@ -307,6 +311,40 @@ class BankTransaction(Document):
|
||||
|
||||
self.party_type, self.party = result
|
||||
|
||||
def validate_included_fee(self):
|
||||
"""
|
||||
The included_fee is only handled for withdrawals. An included_fee for a deposit, is not credited to the account and is
|
||||
therefore outside of the deposit value and can be larger than the deposit itself.
|
||||
"""
|
||||
|
||||
if self.included_fee and self.withdrawal:
|
||||
if self.included_fee > self.withdrawal:
|
||||
frappe.throw(_("Included fee is bigger than the withdrawal itself."))
|
||||
|
||||
def handle_excluded_fee(self):
|
||||
# Include the excluded fee on validate to handle all further processing the same
|
||||
excluded_fee = flt(self.excluded_fee)
|
||||
if excluded_fee <= 0:
|
||||
return
|
||||
|
||||
# Suppress a negative deposit (aka withdrawal), likely not intendend
|
||||
if flt(self.deposit) > 0 and (flt(self.deposit) - excluded_fee) < 0:
|
||||
frappe.throw(_("The Excluded Fee is bigger than the Deposit it is deducted from."))
|
||||
|
||||
# Enforce directionality
|
||||
if flt(self.deposit) > 0 and flt(self.withdrawal) > 0:
|
||||
frappe.throw(
|
||||
_("Only one of Deposit or Withdrawal should be non-zero when applying an Excluded Fee.")
|
||||
)
|
||||
|
||||
if flt(self.deposit) > 0:
|
||||
self.deposit = flt(self.deposit) - excluded_fee
|
||||
# A fee applied to deposit and withdrawal equal 0 become a withdrawal
|
||||
elif flt(self.withdrawal) >= 0:
|
||||
self.withdrawal = flt(self.withdrawal) + excluded_fee
|
||||
self.included_fee = flt(self.included_fee) + excluded_fee
|
||||
self.excluded_fee = 0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_doctypes_for_bank_reconciliation():
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestBankTransactionFees(UnitTestCase):
|
||||
def test_included_fee_throws(self):
|
||||
"""A fee that's part of a withdrawal cannot be bigger than the
|
||||
withdrawal itself."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 101
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.validate_included_fee)
|
||||
|
||||
def test_included_fee_allows_equal(self):
|
||||
"""A fee that's part of a withdrawal may be equal to the withdrawal
|
||||
amount (only the fee was deducted from the account)."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 100
|
||||
|
||||
bt.validate_included_fee()
|
||||
|
||||
def test_included_fee_allows_for_deposit(self):
|
||||
"""For deposits, a fee may be recorded separately without limiting the
|
||||
received amount."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.included_fee = 999
|
||||
|
||||
bt.validate_included_fee()
|
||||
|
||||
def test_excluded_fee_noop_when_zero(self):
|
||||
"""When there is no excluded fee to apply, the amounts should remain
|
||||
unchanged."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 100
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 5
|
||||
bt.excluded_fee = 0
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 100)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_throws_when_exceeds_deposit(self):
|
||||
"""A fee deducted from an incoming payment must not exceed the incoming
|
||||
amount (else it would be a withdrawal, a conversion we don't support)."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.excluded_fee = 11
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
|
||||
|
||||
def test_excluded_fee_throws_when_both_deposit_and_withdrawal_are_set(self):
|
||||
"""A transaction must be either incoming or outgoing when applying a
|
||||
fee, not both."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 10
|
||||
bt.withdrawal = 10
|
||||
bt.excluded_fee = 1
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bt.handle_excluded_fee)
|
||||
|
||||
def test_excluded_fee_deducts_from_deposit(self):
|
||||
"""When a fee is deducted from an incoming payment, the net received
|
||||
amount decreases and the fee is tracked as included."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 100
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 2
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 95)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 7)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_can_reduce_an_incoming_payment_to_zero(self):
|
||||
"""A separately-deducted fee may reduce an incoming payment to zero,
|
||||
while still tracking the fee."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 5
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 0
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 0)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_increases_outgoing_payment(self):
|
||||
"""When a separately-deducted fee is provided for an outgoing payment,
|
||||
the total money leaving increases and the fee is tracked."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 0
|
||||
bt.withdrawal = 100
|
||||
bt.included_fee = 2
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 105)
|
||||
self.assertEqual(bt.included_fee, 7)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
|
||||
def test_excluded_fee_turns_zero_amount_into_withdrawal(self):
|
||||
"""If only an excluded fee is provided, it should be treated as an
|
||||
outgoing payment and the fee is then tracked as included."""
|
||||
bt = frappe.new_doc("Bank Transaction")
|
||||
bt.deposit = 0
|
||||
bt.withdrawal = 0
|
||||
bt.included_fee = 0
|
||||
bt.excluded_fee = 5
|
||||
|
||||
bt.handle_excluded_fee()
|
||||
|
||||
self.assertEqual(bt.deposit, 0)
|
||||
self.assertEqual(bt.withdrawal, 5)
|
||||
self.assertEqual(bt.included_fee, 5)
|
||||
self.assertEqual(bt.excluded_fee, 0)
|
||||
@@ -12,6 +12,15 @@ frappe.ui.form.on("Budget", {
|
||||
};
|
||||
});
|
||||
|
||||
frm.set_query("account", function () {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
|
||||
frappe.db.get_single_value("Accounts Settings", "use_legacy_budget_controller").then((value) => {
|
||||
if (value) {
|
||||
@@ -24,24 +33,16 @@ frappe.ui.form.on("Budget", {
|
||||
frm.trigger("toggle_reqd_fields");
|
||||
|
||||
if (!frm.doc.__islocal && frm.doc.docstatus == 1) {
|
||||
let exception_role = await frappe.db.get_value(
|
||||
"Company",
|
||||
frm.doc.company,
|
||||
"exception_budget_approver_role"
|
||||
frm.add_custom_button(
|
||||
__("Revise Budget"),
|
||||
function () {
|
||||
frm.events.revise_budget_action(frm);
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
|
||||
const role = exception_role.message.exception_budget_approver_role;
|
||||
|
||||
if (role && frappe.user.has_role(role)) {
|
||||
frm.add_custom_button(
|
||||
__("Revise Budget"),
|
||||
function () {
|
||||
frm.events.revise_budget_action(frm);
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toggle_distribution_fields(frm);
|
||||
},
|
||||
|
||||
budget_against: function (frm) {
|
||||
@@ -54,10 +55,15 @@ frappe.ui.form.on("Budget", {
|
||||
frm.doc.budget_distribution.forEach((row) => {
|
||||
row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2);
|
||||
});
|
||||
set_total_budget_amount(frm);
|
||||
frm.refresh_field("budget_distribution");
|
||||
}
|
||||
},
|
||||
|
||||
distribute_equally: function (frm) {
|
||||
toggle_distribution_fields(frm);
|
||||
},
|
||||
|
||||
set_null_value: function (frm) {
|
||||
if (frm.doc.budget_against == "Cost Center") {
|
||||
frm.set_value("project", null);
|
||||
@@ -100,6 +106,8 @@ frappe.ui.form.on("Budget Distribution", {
|
||||
let row = frappe.get_doc(cdt, cdn);
|
||||
if (frm.doc.budget_amount) {
|
||||
row.percent = flt((row.amount / frm.doc.budget_amount) * 100, 2);
|
||||
|
||||
set_total_budget_amount(frm);
|
||||
frm.refresh_field("budget_distribution");
|
||||
}
|
||||
},
|
||||
@@ -107,7 +115,29 @@ frappe.ui.form.on("Budget Distribution", {
|
||||
let row = frappe.get_doc(cdt, cdn);
|
||||
if (frm.doc.budget_amount) {
|
||||
row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2);
|
||||
|
||||
set_total_budget_amount(frm);
|
||||
frm.refresh_field("budget_distribution");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function set_total_budget_amount(frm) {
|
||||
let total = 0;
|
||||
|
||||
(frm.doc.budget_distribution || []).forEach((row) => {
|
||||
total += flt(row.amount);
|
||||
});
|
||||
|
||||
frm.set_value("budget_distribution_total", total);
|
||||
}
|
||||
|
||||
function toggle_distribution_fields(frm) {
|
||||
const grid = frm.fields_dict.budget_distribution.grid;
|
||||
|
||||
["amount", "percent"].forEach((field) => {
|
||||
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
|
||||
});
|
||||
|
||||
grid.refresh();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"distribute_equally",
|
||||
"section_break_fpdt",
|
||||
"budget_distribution",
|
||||
"section_break_wkqb",
|
||||
"column_break_paum",
|
||||
"column_break_nwor",
|
||||
"budget_distribution_total",
|
||||
"section_break_6",
|
||||
"applicable_on_material_request",
|
||||
"action_if_annual_budget_exceeded_on_mr",
|
||||
@@ -222,7 +226,8 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_fpdt",
|
||||
"fieldtype": "Section Break"
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "budget_distribution",
|
||||
@@ -303,13 +308,32 @@
|
||||
"options": "Monthly\nQuarterly\nHalf-Yearly\nYearly",
|
||||
"read_only_depends_on": "eval: doc.revision_of",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_wkqb",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_paum",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_nwor",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "budget_distribution_total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Budget Distribution Total",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-19 17:00:00.648224",
|
||||
"modified": "2025-12-10 02:35:01.197613",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budget",
|
||||
|
||||
@@ -53,6 +53,7 @@ class Budget(Document):
|
||||
budget_against: DF.Literal["", "Cost Center", "Project"]
|
||||
budget_amount: DF.Currency
|
||||
budget_distribution: DF.Table[BudgetDistribution]
|
||||
budget_distribution_total: DF.Currency
|
||||
budget_end_date: DF.Date | None
|
||||
budget_start_date: DF.Date | None
|
||||
company: DF.Link
|
||||
@@ -230,28 +231,49 @@ class Budget(Document):
|
||||
|
||||
def before_save(self):
|
||||
self.allocate_budget()
|
||||
self.budget_distribution_total = sum(flt(row.amount) for row in self.budget_distribution)
|
||||
|
||||
def on_update(self):
|
||||
self.validate_distribution_totals()
|
||||
|
||||
def allocate_budget(self):
|
||||
if self.revision_of:
|
||||
if self._should_skip_allocation():
|
||||
return
|
||||
|
||||
if self._should_recalculate_manual_distribution():
|
||||
self._recalculate_manual_distribution()
|
||||
return
|
||||
|
||||
if not self.should_regenerate_budget_distribution():
|
||||
return
|
||||
|
||||
self.set("budget_distribution", [])
|
||||
self._regenerate_distribution()
|
||||
|
||||
periods = self.get_budget_periods()
|
||||
total_periods = len(periods)
|
||||
row_percent = 100 / total_periods if total_periods else 0
|
||||
def _should_skip_allocation(self):
|
||||
return self.revision_of and not self.distribute_equally
|
||||
|
||||
for start_date, end_date in periods:
|
||||
row = self.append("budget_distribution", {})
|
||||
row.start_date = start_date
|
||||
row.end_date = end_date
|
||||
self.add_allocated_amount(row, row_percent)
|
||||
def _should_recalculate_manual_distribution(self):
|
||||
return (
|
||||
not self.distribute_equally
|
||||
and bool(self.budget_distribution)
|
||||
and self._is_only_budget_amount_changed()
|
||||
)
|
||||
|
||||
def _is_only_budget_amount_changed(self):
|
||||
old = self.get_doc_before_save()
|
||||
if not old:
|
||||
return False
|
||||
|
||||
return (
|
||||
old.budget_amount != self.budget_amount
|
||||
and old.distribution_frequency == self.distribution_frequency
|
||||
and old.budget_start_date == self.budget_start_date
|
||||
and old.budget_end_date == self.budget_end_date
|
||||
)
|
||||
|
||||
def _recalculate_manual_distribution(self):
|
||||
for row in self.budget_distribution:
|
||||
row.amount = flt((row.percent / 100) * self.budget_amount, 3)
|
||||
|
||||
def should_regenerate_budget_distribution(self):
|
||||
"""Check whether budget distribution should be recalculated."""
|
||||
@@ -265,7 +287,6 @@ class Budget(Document):
|
||||
"to_fiscal_year",
|
||||
"budget_amount",
|
||||
"distribution_frequency",
|
||||
"distribute_equally",
|
||||
]
|
||||
for field in changed_fields:
|
||||
if old_doc.get(field) != self.get(field):
|
||||
@@ -273,6 +294,21 @@ class Budget(Document):
|
||||
|
||||
return bool(self.distribute_equally)
|
||||
|
||||
def _regenerate_distribution(self):
|
||||
self.set("budget_distribution", [])
|
||||
|
||||
periods = self.get_budget_periods()
|
||||
total_periods = len(periods)
|
||||
row_percent = 100 / total_periods if total_periods else 0
|
||||
|
||||
for start_date, end_date in periods:
|
||||
row = self.append("budget_distribution", {})
|
||||
row.start_date = start_date
|
||||
row.end_date = end_date
|
||||
self.add_allocated_amount(row, row_percent)
|
||||
|
||||
self.budget_distribution_total = self.budget_amount
|
||||
|
||||
def get_budget_periods(self):
|
||||
"""Return list of (start_date, end_date) tuples based on frequency."""
|
||||
frequency = self.distribution_frequency
|
||||
@@ -312,12 +348,8 @@ class Budget(Document):
|
||||
}.get(frequency, 1)
|
||||
|
||||
def add_allocated_amount(self, row, row_percent):
|
||||
if not self.distribute_equally:
|
||||
row.amount = 0
|
||||
row.percent = 0
|
||||
else:
|
||||
row.amount = flt(self.budget_amount * row_percent / 100, 3)
|
||||
row.percent = flt(row_percent, 3)
|
||||
row.amount = flt(self.budget_amount * row_percent / 100, 3)
|
||||
row.percent = flt(row_percent, 3)
|
||||
|
||||
def validate_distribution_totals(self):
|
||||
if self.should_regenerate_budget_distribution():
|
||||
|
||||
@@ -19,7 +19,7 @@ frappe.ui.form.on("Currency Exchange Settings", {
|
||||
to: "{to_currency}",
|
||||
};
|
||||
add_param(frm, r.message, params, result);
|
||||
} else if (frm.doc.service_provider == "frankfurter.dev") {
|
||||
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
|
||||
let result = ["rates", "{to_currency}"];
|
||||
let params = {
|
||||
base: "{from_currency}",
|
||||
|
||||
@@ -60,7 +60,7 @@ class CurrencyExchangeSettings(Document):
|
||||
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
|
||||
self.append("req_params", {"key": "from", "value": "{from_currency}"})
|
||||
self.append("req_params", {"key": "to", "value": "{to_currency}"})
|
||||
elif self.service_provider == "frankfurter.dev":
|
||||
elif self.service_provider in ("frankfurter.dev", "frankfurter.app"):
|
||||
self.set("result_key", [])
|
||||
self.set("req_params", [])
|
||||
|
||||
@@ -105,9 +105,11 @@ class CurrencyExchangeSettings(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev"]:
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
|
||||
if service_provider == "exchangerate.host":
|
||||
api = "api.exchangerate.host/convert"
|
||||
elif service_provider == "frankfurter.app":
|
||||
api = "api.frankfurter.app/{transaction_date}"
|
||||
elif service_provider == "frankfurter.dev":
|
||||
api = "api.frankfurter.dev/v1/{transaction_date}"
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
|
||||
get_depr_schedule,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate
|
||||
|
||||
|
||||
class StockAccountInvalidTransaction(frappe.ValidationError):
|
||||
@@ -353,93 +354,7 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
|
||||
def apply_tax_withholding(self):
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map
|
||||
|
||||
if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
|
||||
parties = [d.party for d in self.get("accounts") if d.party]
|
||||
parties = list(set(parties))
|
||||
|
||||
if len(parties) > 1:
|
||||
frappe.throw(_("Cannot apply TDS against multiple parties in one entry"))
|
||||
|
||||
account_type_map = get_account_type_map(self.company)
|
||||
party_type = "supplier" if self.voucher_type == "Credit Note" else "customer"
|
||||
doctype = "Purchase Invoice" if self.voucher_type == "Credit Note" else "Sales Invoice"
|
||||
debit_or_credit = (
|
||||
"debit_in_account_currency"
|
||||
if self.voucher_type == "Credit Note"
|
||||
else "credit_in_account_currency"
|
||||
)
|
||||
rev_debit_or_credit = (
|
||||
"credit_in_account_currency"
|
||||
if debit_or_credit == "debit_in_account_currency"
|
||||
else "debit_in_account_currency"
|
||||
)
|
||||
|
||||
party_account = get_party_account(party_type.title(), parties[0], self.company)
|
||||
|
||||
net_total = sum(
|
||||
d.get(debit_or_credit)
|
||||
for d in self.get("accounts")
|
||||
if account_type_map.get(d.account) not in ("Tax", "Chargeable")
|
||||
)
|
||||
|
||||
party_amount = sum(
|
||||
d.get(rev_debit_or_credit) for d in self.get("accounts") if d.account == party_account
|
||||
)
|
||||
|
||||
inv = frappe._dict(
|
||||
{
|
||||
party_type: parties[0],
|
||||
"doctype": doctype,
|
||||
"company": self.company,
|
||||
"posting_date": self.posting_date,
|
||||
"net_total": net_total,
|
||||
}
|
||||
)
|
||||
|
||||
tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details(
|
||||
inv, self.tax_withholding_category
|
||||
)
|
||||
|
||||
if not tax_withholding_details:
|
||||
return
|
||||
|
||||
accounts = []
|
||||
for d in self.get("accounts"):
|
||||
if d.get("account") == tax_withholding_details.get("account_head"):
|
||||
d.update(
|
||||
{
|
||||
"account": tax_withholding_details.get("account_head"),
|
||||
debit_or_credit: tax_withholding_details.get("tax_amount"),
|
||||
}
|
||||
)
|
||||
|
||||
accounts.append(d.get("account"))
|
||||
|
||||
if d.get("account") == party_account:
|
||||
d.update({rev_debit_or_credit: party_amount - tax_withholding_details.get("tax_amount")})
|
||||
|
||||
if not accounts or tax_withholding_details.get("account_head") not in accounts:
|
||||
self.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": tax_withholding_details.get("account_head"),
|
||||
rev_debit_or_credit: tax_withholding_details.get("tax_amount"),
|
||||
"against_account": parties[0],
|
||||
},
|
||||
)
|
||||
|
||||
to_remove = [
|
||||
d
|
||||
for d in self.get("accounts")
|
||||
if not d.get(rev_debit_or_credit) and d.account == tax_withholding_details.get("account_head")
|
||||
]
|
||||
|
||||
for d in to_remove:
|
||||
self.remove(d)
|
||||
JournalEntryTaxWithholding(self).apply()
|
||||
|
||||
def update_asset_value(self):
|
||||
self.update_asset_on_depreciation()
|
||||
@@ -1382,6 +1297,230 @@ class JournalEntry(AccountsController):
|
||||
frappe.throw(_("Accounts table cannot be blank."))
|
||||
|
||||
|
||||
class JournalEntryTaxWithholding:
|
||||
def __init__(self, journal_entry):
|
||||
self.doc: JournalEntry = journal_entry
|
||||
self.party = None
|
||||
self.party_type = None
|
||||
self.party_account = None
|
||||
self.party_row = None
|
||||
self.existing_tds_rows = []
|
||||
self.precision = None
|
||||
self.has_multiple_parties = False
|
||||
|
||||
# Direction fields based on party type
|
||||
self.party_field = None # "credit" for Supplier, "debit" for Customer
|
||||
self.reverse_field = None # opposite of party_field
|
||||
|
||||
def apply(self):
|
||||
if not self._set_party_info():
|
||||
return
|
||||
|
||||
self._setup_direction_fields()
|
||||
self._reset_existing_tds()
|
||||
|
||||
if not self._should_apply_tds():
|
||||
self._cleanup_duplicate_tds_rows(None)
|
||||
return
|
||||
|
||||
if self.has_multiple_parties:
|
||||
frappe.throw(_("Cannot apply TDS against multiple parties in one entry"))
|
||||
|
||||
net_total = self._calculate_net_total()
|
||||
if net_total <= 0:
|
||||
return
|
||||
|
||||
tds_details = self._get_tds_details(net_total)
|
||||
if not tds_details or not tds_details.get("tax_amount"):
|
||||
return
|
||||
|
||||
self._create_or_update_tds_row(tds_details)
|
||||
self._update_party_amount(tds_details.get("tax_amount"), is_reversal=False)
|
||||
|
||||
self._recalculate_totals()
|
||||
|
||||
def _should_apply_tds(self):
|
||||
return self.doc.apply_tds and self.doc.voucher_type in ("Debit Note", "Credit Note")
|
||||
|
||||
def _set_party_info(self):
|
||||
for row in self.doc.get("accounts"):
|
||||
if row.party_type in ("Customer", "Supplier") and row.party:
|
||||
if self.party and row.party != self.party:
|
||||
self.has_multiple_parties = True
|
||||
|
||||
if not self.party:
|
||||
self.party = row.party
|
||||
self.party_type = row.party_type
|
||||
self.party_account = row.account
|
||||
self.party_row = row
|
||||
|
||||
if row.get("is_tax_withholding_account"):
|
||||
self.existing_tds_rows.append(row)
|
||||
|
||||
return bool(self.party)
|
||||
|
||||
def _setup_direction_fields(self):
|
||||
"""
|
||||
For Supplier (TDS): party has credit, TDS reduces credit
|
||||
For Customer (TCS): party has debit, TCS increases debit
|
||||
"""
|
||||
if self.party_type == "Supplier":
|
||||
self.party_field = "credit"
|
||||
self.reverse_field = "debit"
|
||||
else: # Customer
|
||||
self.party_field = "debit"
|
||||
self.reverse_field = "credit"
|
||||
|
||||
self.precision = self.doc.precision(self.party_field, self.party_row)
|
||||
|
||||
def _reset_existing_tds(self):
|
||||
for row in self.existing_tds_rows:
|
||||
# TDS amount is always in credit (liability to government)
|
||||
tds_amount = flt(row.get("credit") - row.get("debit"), self.precision)
|
||||
if not tds_amount:
|
||||
continue
|
||||
|
||||
self._update_party_amount(tds_amount, is_reversal=True)
|
||||
|
||||
# zero_out_tds_row
|
||||
row.update(
|
||||
{
|
||||
"credit": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit": 0,
|
||||
"debit_in_account_currency": 0,
|
||||
}
|
||||
)
|
||||
|
||||
def _update_party_amount(self, amount, is_reversal=False):
|
||||
amount = flt(amount, self.precision)
|
||||
amount_in_party_currency = flt(amount / self.party_row.get("exchange_rate", 1), self.precision)
|
||||
|
||||
# Determine which field the party amount is in
|
||||
active_field = self.party_field if self.party_row.get(self.party_field) else self.reverse_field
|
||||
|
||||
# If amount is in reverse field, flip the signs
|
||||
if active_field == self.reverse_field:
|
||||
amount = -amount
|
||||
amount_in_party_currency = -amount_in_party_currency
|
||||
|
||||
# Direction multiplier based on party type:
|
||||
# Customer (TCS): +1 (add to debit)
|
||||
# Supplier (TDS): -1 (subtract from credit)
|
||||
direction = 1 if self.party_type == "Customer" else -1
|
||||
|
||||
# Reversal inverts the direction
|
||||
if is_reversal:
|
||||
direction = -direction
|
||||
|
||||
adjustment = amount * direction
|
||||
adjustment_in_party_currency = amount_in_party_currency * direction
|
||||
|
||||
active_field_account_currency = f"{active_field}_in_account_currency"
|
||||
|
||||
self.party_row.update(
|
||||
{
|
||||
active_field: flt(self.party_row.get(active_field) + adjustment, self.precision),
|
||||
active_field_account_currency: flt(
|
||||
self.party_row.get(active_field_account_currency) + adjustment_in_party_currency,
|
||||
self.precision,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def _calculate_net_total(self):
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map
|
||||
|
||||
account_type_map = get_account_type_map(self.doc.company)
|
||||
|
||||
return flt(
|
||||
sum(
|
||||
d.get(self.reverse_field) - d.get(self.party_field)
|
||||
for d in self.doc.get("accounts")
|
||||
if account_type_map.get(d.account) not in ("Tax", "Chargeable")
|
||||
and d.account != self.party_account
|
||||
and not d.get("is_tax_withholding_account")
|
||||
),
|
||||
self.precision,
|
||||
)
|
||||
|
||||
def _get_tds_details(self, net_total):
|
||||
return get_party_tax_withholding_details(
|
||||
frappe._dict(
|
||||
{
|
||||
"party_type": self.party_type,
|
||||
"party": self.party,
|
||||
"doctype": self.doc.doctype,
|
||||
"company": self.doc.company,
|
||||
"posting_date": self.doc.posting_date,
|
||||
"tax_withholding_net_total": net_total,
|
||||
"base_tax_withholding_net_total": net_total,
|
||||
"grand_total": net_total,
|
||||
}
|
||||
),
|
||||
self.doc.tax_withholding_category,
|
||||
)
|
||||
|
||||
def _create_or_update_tds_row(self, tds_details):
|
||||
tax_account = tds_details.get("account_head")
|
||||
account_currency = get_account_currency(tax_account)
|
||||
company_currency = frappe.get_cached_value("Company", self.doc.company, "default_currency")
|
||||
exchange_rate = _get_exchange_rate(account_currency, company_currency, self.doc.posting_date)
|
||||
|
||||
tax_amount = flt(tds_details.get("tax_amount"), self.precision)
|
||||
tax_amount_in_account_currency = flt(tax_amount / exchange_rate, self.precision)
|
||||
|
||||
# Find existing TDS row for this account
|
||||
tax_row = None
|
||||
for row in self.doc.get("accounts"):
|
||||
if row.account == tax_account and row.get("is_tax_withholding_account"):
|
||||
tax_row = row
|
||||
break
|
||||
|
||||
if not tax_row:
|
||||
tax_row = self.doc.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": tax_account,
|
||||
"account_currency": account_currency,
|
||||
"exchange_rate": exchange_rate,
|
||||
"cost_center": tds_details.get("cost_center"),
|
||||
"credit": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit": 0,
|
||||
"debit_in_account_currency": 0,
|
||||
"is_tax_withholding_account": 1,
|
||||
},
|
||||
)
|
||||
|
||||
# TDS/TCS is always credited (liability to government)
|
||||
tax_row.update(
|
||||
{
|
||||
"credit": tax_amount,
|
||||
"credit_in_account_currency": tax_amount_in_account_currency,
|
||||
"debit": 0,
|
||||
"debit_in_account_currency": 0,
|
||||
}
|
||||
)
|
||||
|
||||
self._cleanup_duplicate_tds_rows(tax_row)
|
||||
|
||||
def _cleanup_duplicate_tds_rows(self, current_tax_row):
|
||||
rows_to_remove = [
|
||||
row
|
||||
for row in self.doc.get("accounts")
|
||||
if row.get("is_tax_withholding_account") and row != current_tax_row
|
||||
]
|
||||
|
||||
for row in rows_to_remove:
|
||||
self.doc.remove(row)
|
||||
|
||||
def _recalculate_totals(self):
|
||||
self.doc.set_amounts_in_company_currency()
|
||||
self.doc.set_total_debit_credit()
|
||||
self.doc.set_against_account()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_default_bank_cash_account(
|
||||
company, account_type=None, mode_of_payment=None, account=None, *, fetch_balance=True
|
||||
@@ -1754,8 +1893,6 @@ def get_exchange_rate(
|
||||
credit=None,
|
||||
exchange_rate=None,
|
||||
):
|
||||
from erpnext.setup.utils import get_exchange_rate
|
||||
|
||||
account_details = frappe.get_cached_value(
|
||||
"Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1
|
||||
)
|
||||
@@ -1778,7 +1915,7 @@ def get_exchange_rate(
|
||||
# The date used to retreive the exchange rate here is the date passed
|
||||
# in as an argument to this function.
|
||||
elif (not flt(exchange_rate) or flt(exchange_rate) == 1) and account_currency and posting_date:
|
||||
exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date)
|
||||
exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date)
|
||||
else:
|
||||
exchange_rate = 1
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"reference_detail_no",
|
||||
"advance_voucher_type",
|
||||
"advance_voucher_no",
|
||||
"is_tax_withholding_account",
|
||||
"col_break3",
|
||||
"is_advance",
|
||||
"user_remark",
|
||||
@@ -281,12 +282,19 @@
|
||||
"options": "advance_voucher_type",
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_tax_withholding_account",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Tax Withholding Account",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-27 13:48:32.805100",
|
||||
"modified": "2025-11-27 12:23:33.157655",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Journal Entry Account",
|
||||
|
||||
@@ -28,6 +28,7 @@ class JournalEntryAccount(Document):
|
||||
debit_in_account_currency: DF.Currency
|
||||
exchange_rate: DF.Float
|
||||
is_advance: DF.Literal["No", "Yes"]
|
||||
is_tax_withholding_account: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
|
||||
@@ -214,6 +214,9 @@ class OpeningInvoiceCreationTool(Document):
|
||||
}
|
||||
)
|
||||
|
||||
if self.invoice_type == "Purchase" and row.supplier_invoice_date:
|
||||
invoice.update({"bill_date": row.supplier_invoice_date})
|
||||
|
||||
accounting_dimension = get_accounting_dimensions()
|
||||
for dimension in accounting_dimension:
|
||||
invoice.update({dimension: self.get(dimension) or item.get(dimension)})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"column_break_3",
|
||||
"posting_date",
|
||||
"due_date",
|
||||
"supplier_invoice_date",
|
||||
"section_break_5",
|
||||
"item_name",
|
||||
"outstanding_amount",
|
||||
@@ -111,19 +112,26 @@
|
||||
"fieldname": "invoice_number",
|
||||
"fieldtype": "Data",
|
||||
"label": "Invoice Number"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: parent.invoice_type == \"Purchase\"",
|
||||
"fieldname": "supplier_invoice_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Supplier Invoice Date"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:06.703006",
|
||||
"modified": "2025-12-01 16:18:07.997594",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class OpeningInvoiceCreationToolItem(Document):
|
||||
party_type: DF.Link | None
|
||||
posting_date: DF.Date | None
|
||||
qty: DF.Data | None
|
||||
supplier_invoice_date: DF.Date | None
|
||||
temporary_opening_account: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -1277,15 +1277,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
let row = (frm.doc.deductions || []).find((t) => t.is_exchange_gain_loss);
|
||||
|
||||
if (!row) {
|
||||
const response = await get_company_defaults(frm.doc.company);
|
||||
|
||||
const company_defaults = frappe.get_doc(":Company", frm.doc.company);
|
||||
const account =
|
||||
response.message?.[account_fieldname] ||
|
||||
company_defaults?.[account_fieldname] ||
|
||||
(await prompt_for_missing_account(frm, account_fieldname));
|
||||
|
||||
row = frm.add_child("deductions");
|
||||
row.account = account;
|
||||
row.cost_center = response.message?.cost_center;
|
||||
row.cost_center = company_defaults?.cost_center;
|
||||
row.is_exchange_gain_loss = 1;
|
||||
}
|
||||
|
||||
@@ -1495,18 +1494,14 @@ frappe.ui.form.on("Payment Entry", {
|
||||
"Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'"
|
||||
);
|
||||
d.row_id = "";
|
||||
} else if (
|
||||
(d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") &&
|
||||
d.row_id
|
||||
) {
|
||||
} else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") {
|
||||
if (d.idx == 1) {
|
||||
msg = __(
|
||||
"Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
|
||||
);
|
||||
d.charge_type = "";
|
||||
} else if (!d.row_id) {
|
||||
msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]);
|
||||
d.row_id = "";
|
||||
d.row_id = d.idx - 1;
|
||||
} else if (d.row_id && d.row_id >= d.idx) {
|
||||
msg = __(
|
||||
"Cannot refer row number greater than or equal to current row number for this Charge type"
|
||||
|
||||
@@ -593,9 +593,9 @@
|
||||
"label": "Apply Tax Withholding Amount"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "taxes_and_charges_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1,
|
||||
"label": "Taxes and Charges"
|
||||
},
|
||||
{
|
||||
@@ -695,8 +695,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_60",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.docstatus==0",
|
||||
@@ -767,7 +766,7 @@
|
||||
"table_fieldname": "payment_entries"
|
||||
}
|
||||
],
|
||||
"modified": "2025-05-08 11:18:10.238085",
|
||||
"modified": "2025-12-18 13:56:40.206038",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"columns": 2,
|
||||
"columns": 4,
|
||||
"fieldname": "reference_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_global_search": 1,
|
||||
@@ -49,8 +49,10 @@
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"columns": 2,
|
||||
"fieldname": "due_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "Due Date",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -174,7 +176,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-25 04:32:11.040025",
|
||||
"modified": "2025-12-08 13:57:30.098239",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry Reference",
|
||||
|
||||
@@ -427,6 +427,7 @@ class PaymentRequest(Document):
|
||||
context = {
|
||||
"doc": frappe.get_doc(self.reference_doctype, self.reference_name),
|
||||
"payment_url": self.payment_url,
|
||||
"payment_request": self,
|
||||
}
|
||||
|
||||
if self.message:
|
||||
@@ -539,6 +540,9 @@ def make_payment_request(**args):
|
||||
if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST:
|
||||
frappe.throw(_("Payment Requests cannot be created against: {0}").format(frappe.bold(args.dt)))
|
||||
|
||||
if args.dn and not isinstance(args.dn, str):
|
||||
frappe.throw(_("Invalid parameter. 'dn' should be of type str"))
|
||||
|
||||
ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn)
|
||||
if not args.get("company"):
|
||||
args.company = ref_doc.company
|
||||
@@ -843,6 +847,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
|
||||
)
|
||||
|
||||
referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
|
||||
doc_updates = {}
|
||||
|
||||
for ref in references:
|
||||
if not ref.payment_request:
|
||||
@@ -868,7 +873,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
|
||||
title=_("Invalid Allocated Amount"),
|
||||
)
|
||||
|
||||
# update status
|
||||
# determine status
|
||||
if new_outstanding_amount == payment_request["grand_total"]:
|
||||
status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
|
||||
elif new_outstanding_amount == 0:
|
||||
@@ -876,31 +881,37 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False):
|
||||
elif new_outstanding_amount > 0:
|
||||
status = "Partially Paid"
|
||||
|
||||
# update database
|
||||
frappe.db.set_value(
|
||||
"Payment Request",
|
||||
ref.payment_request,
|
||||
{"outstanding_amount": new_outstanding_amount, "status": status},
|
||||
)
|
||||
# prepare bulk update data
|
||||
doc_updates[ref.payment_request] = {
|
||||
"outstanding_amount": new_outstanding_amount,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
# bulk update all payment requests
|
||||
if doc_updates:
|
||||
frappe.db.bulk_update("Payment Request", doc_updates)
|
||||
|
||||
|
||||
def get_dummy_message(doc):
|
||||
return frappe.render_template(
|
||||
"""{% if doc.contact_person -%}
|
||||
<p>Dear {{ doc.contact_person }},</p>
|
||||
{%- else %}<p>Hello,</p>{% endif %}
|
||||
return """
|
||||
{% if doc.contact_person -%}
|
||||
<p>Dear {{ doc.contact_person }},</p>
|
||||
{%- else %}<p>Hello,</p>{% endif %}
|
||||
|
||||
<p>{{ _("Requesting payment against {0} {1} for amount {2}").format(doc.doctype,
|
||||
doc.name, doc.get_formatted("grand_total")) }}</p>
|
||||
<p>
|
||||
{{ _("Requesting payment against {0} {1} for amount {2}").format(
|
||||
doc.doctype,
|
||||
doc.name,
|
||||
payment_request.get_formatted("grand_total")
|
||||
) }}
|
||||
</p>
|
||||
|
||||
<a href="{{ payment_url }}">{{ _("Make Payment") }}</a>
|
||||
<a href="{{ payment_url }}">{{ _("Make Payment") }}</a>
|
||||
|
||||
<p>{{ _("If you have any questions, please get back to us.") }}</p>
|
||||
<p>{{ _("If you have any questions, please get back to us.") }}</p>
|
||||
|
||||
<p>{{ _("Thank you for your business!") }}</p>
|
||||
""",
|
||||
dict(doc=doc, payment_url="{{ payment_url }}"),
|
||||
)
|
||||
<p>{{ _("Thank you for your business!") }}</p>
|
||||
"""
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -130,7 +130,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
|
||||
if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
|
||||
this.frm.add_custom_button(
|
||||
__("Payment Request"),
|
||||
function () {
|
||||
@@ -579,17 +579,6 @@ cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function
|
||||
};
|
||||
};
|
||||
|
||||
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
if (d.cost_center) {
|
||||
var cl = doc.items || [];
|
||||
for (var i = 0; i < cl.length; i++) {
|
||||
if (!cl[i].cost_center) cl[i].cost_center = d.cost_center;
|
||||
}
|
||||
}
|
||||
refresh_field("items");
|
||||
};
|
||||
|
||||
cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) {
|
||||
return {
|
||||
filters: [["Project", "status", "not in", "Completed, Cancelled"]],
|
||||
|
||||
@@ -2027,6 +2027,7 @@ def get_list_context(context=None):
|
||||
"show_search": True,
|
||||
"no_breadcrumbs": True,
|
||||
"title": _("Purchase Invoices"),
|
||||
"list_template": "templates/includes/list/list.html",
|
||||
}
|
||||
)
|
||||
return list_context
|
||||
@@ -2097,10 +2098,26 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
|
||||
def post_parent_process(source_parent, target_parent):
|
||||
for row in target_parent.get("items"):
|
||||
if row.get("qty") == 0:
|
||||
target_parent.remove(row)
|
||||
|
||||
def update_item(obj, target, source_parent):
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
|
||||
|
||||
returned_qty_map = (
|
||||
get_returned_qty_map_for_row(
|
||||
source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice"
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
|
||||
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
|
||||
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
|
||||
obj.conversion_factor
|
||||
)
|
||||
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
|
||||
target.base_amount = (
|
||||
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
|
||||
@@ -2139,6 +2156,7 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
|
||||
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
|
||||
},
|
||||
target_doc,
|
||||
post_parent_process,
|
||||
)
|
||||
|
||||
return doc
|
||||
|
||||
@@ -2931,6 +2931,29 @@ class TestPurchaseInvoice(IntegrationTestCase, StockTestMixin):
|
||||
pi.save()
|
||||
self.assertEqual(pi.discount_amount, discount_amount)
|
||||
|
||||
def test_returned_item_purchase_receipt(self):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
make_purchase_receipt as make_purchase_receipt_from_pi,
|
||||
)
|
||||
|
||||
item = create_item("_Test Returned Item Purchase Receipt", is_stock_item=1)
|
||||
|
||||
pi = make_purchase_invoice(item_code=item.name, qty=5, rate=100)
|
||||
|
||||
return_pi = make_purchase_invoice(
|
||||
item_code=item.name,
|
||||
is_return=1,
|
||||
return_against=pi.name,
|
||||
qty=-5,
|
||||
do_not_submit=True,
|
||||
)
|
||||
|
||||
return_pi.items[0].purchase_invoice_item = pi.items[0].name
|
||||
return_pi.submit()
|
||||
|
||||
pr = make_purchase_receipt_from_pi(pi.name)
|
||||
self.assertFalse(pr.items)
|
||||
|
||||
|
||||
def set_advance_flag(company, flag, default_account):
|
||||
frappe.db.set_value(
|
||||
|
||||
@@ -228,7 +228,6 @@
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"depends_on": "eval:doc.uom != doc.stock_uom",
|
||||
"fieldname": "conversion_factor",
|
||||
"fieldtype": "Float",
|
||||
@@ -985,7 +984,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-14 13:00:54.441511",
|
||||
"modified": "2025-12-13 14:10:02.379392",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -618,10 +618,6 @@ cur_frm.cscript.expense_account = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "expense_account");
|
||||
};
|
||||
|
||||
cur_frm.cscript.cost_center = function (doc, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(doc, cdt, cdn, "items", "cost_center");
|
||||
};
|
||||
|
||||
frappe.ui.form.on("Sales Invoice", {
|
||||
setup: function (frm) {
|
||||
frm.add_fetch("customer", "tax_id", "tax_id");
|
||||
|
||||
@@ -2326,6 +2326,7 @@ def get_list_context(context=None):
|
||||
"show_search": True,
|
||||
"no_breadcrumbs": True,
|
||||
"title": _("Invoices"),
|
||||
"list_template": "templates/includes/list/list.html",
|
||||
}
|
||||
)
|
||||
return list_context
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "rate",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Rate",
|
||||
"read_only": 1,
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount",
|
||||
"read_only": 1,
|
||||
@@ -91,15 +91,16 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:39.866399",
|
||||
"modified": "2025-12-10 08:06:40.611761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Balance",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ShareBalance(Document):
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
amount: DF.Int
|
||||
amount: DF.Currency
|
||||
current_state: DF.Literal["", "Issued", "Purchased"]
|
||||
from_no: DF.Int
|
||||
is_company: DF.Check
|
||||
@@ -22,7 +22,7 @@ class ShareBalance(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
rate: DF.Int
|
||||
rate: DF.Currency
|
||||
share_type: DF.Link
|
||||
to_no: DF.Int
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -85,6 +85,9 @@ def get_party_details(inv):
|
||||
if inv.doctype == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party = inv.customer
|
||||
elif inv.doctype == "Journal Entry":
|
||||
party_type = inv.party_type
|
||||
party = inv.party
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party = inv.supplier
|
||||
@@ -155,7 +158,7 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
|
||||
party_type, parties, inv, tax_details, posting_date, pan_no
|
||||
)
|
||||
|
||||
if party_type == "Supplier":
|
||||
if party_type == "Supplier" or inv.doctype == "Journal Entry":
|
||||
tax_row = get_tax_row_for_tds(tax_details, tax_amount)
|
||||
else:
|
||||
tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted)
|
||||
@@ -346,7 +349,10 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
|
||||
elif party_type == "Customer":
|
||||
if tax_deducted:
|
||||
# if already TCS is charged, then amount will be calculated based on 'Previous Row Total'
|
||||
tax_amount = 0
|
||||
if inv.doctype == "Sales Invoice":
|
||||
tax_amount = 0
|
||||
else:
|
||||
tax_amount = inv.base_tax_withholding_net_total * tax_details.rate / 100
|
||||
else:
|
||||
# if no TCS has been charged in FY,
|
||||
# then chargeable value is "prev invoices + advances - advance_adjusted" value which cross the threshold
|
||||
@@ -718,7 +724,7 @@ def get_advance_adjusted_in_invoice(inv):
|
||||
|
||||
|
||||
def get_invoice_total_without_tcs(inv, tax_details):
|
||||
tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head]
|
||||
tcs_tax_row = [d for d in inv.get("taxes") or [] if d.account_head == tax_details.account_head]
|
||||
tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0
|
||||
|
||||
return inv.grand_total - tcs_tax_row_amount
|
||||
|
||||
@@ -849,6 +849,90 @@ class TestTaxWithholdingCategory(IntegrationTestCase):
|
||||
self.assertEqual(payment.taxes[0].tax_amount, 6000)
|
||||
self.assertEqual(payment.taxes[0].allocated_amount, 6000)
|
||||
|
||||
def test_tds_on_journal_entry_for_supplier(self):
|
||||
"""Test TDS deduction for Supplier in Debit Note"""
|
||||
frappe.db.set_value(
|
||||
"Supplier", "Test TDS Supplier", "tax_withholding_category", "Cumulative Threshold TDS"
|
||||
)
|
||||
|
||||
jv = make_journal_entry_with_tax_withholding(
|
||||
party_type="Supplier",
|
||||
party="Test TDS Supplier",
|
||||
voucher_type="Debit Note",
|
||||
amount=50000,
|
||||
save=False,
|
||||
)
|
||||
jv.apply_tds = 1
|
||||
jv.tax_withholding_category = "Cumulative Threshold TDS"
|
||||
jv.save()
|
||||
|
||||
# Again saving should not change tds amount
|
||||
jv.user_remark = "Test TDS on Journal Entry for Supplier"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
# TDS = 50000 * 10% = 5000
|
||||
self.assertEqual(len(jv.accounts), 3)
|
||||
|
||||
# Find TDS account row
|
||||
tds_row = None
|
||||
supplier_row = None
|
||||
for row in jv.accounts:
|
||||
if row.account == "TDS - _TC":
|
||||
tds_row = row
|
||||
elif row.party == "Test TDS Supplier":
|
||||
supplier_row = row
|
||||
|
||||
self.assertEqual(tds_row.credit, 5000)
|
||||
self.assertEqual(tds_row.debit, 0)
|
||||
|
||||
# Supplier amount should be reduced by TDS
|
||||
self.assertEqual(supplier_row.credit, 45000)
|
||||
jv.cancel()
|
||||
|
||||
def test_tcs_on_journal_entry_for_customer(self):
|
||||
"""Test TCS collection for Customer in Credit Note"""
|
||||
frappe.db.set_value(
|
||||
"Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS"
|
||||
)
|
||||
|
||||
# Create Credit Note with amount exceeding threshold
|
||||
jv = make_journal_entry_with_tax_withholding(
|
||||
party_type="Customer",
|
||||
party="Test TCS Customer",
|
||||
voucher_type="Credit Note",
|
||||
amount=50000,
|
||||
save=False,
|
||||
)
|
||||
jv.apply_tds = 1
|
||||
jv.tax_withholding_category = "Cumulative Threshold TCS"
|
||||
jv.save()
|
||||
|
||||
# Again saving should not change tds amount
|
||||
jv.user_remark = "Test TCS on Journal Entry for Customer"
|
||||
jv.save()
|
||||
jv.submit()
|
||||
|
||||
# Assert TCS calculation (10% on amount above threshold of 30000)
|
||||
self.assertEqual(len(jv.accounts), 3)
|
||||
|
||||
# Find TCS account row
|
||||
tcs_row = None
|
||||
customer_row = None
|
||||
for row in jv.accounts:
|
||||
if row.account == "TCS - _TC":
|
||||
tcs_row = row
|
||||
elif row.party == "Test TCS Customer":
|
||||
customer_row = row
|
||||
|
||||
# TCS should be credited (liability to government)
|
||||
self.assertEqual(tcs_row.credit, 2000) # above threshold 20000*10%
|
||||
self.assertEqual(tcs_row.debit, 0)
|
||||
|
||||
# Customer amount should be increased by TCS
|
||||
self.assertEqual(customer_row.debit, 52000)
|
||||
jv.cancel()
|
||||
|
||||
|
||||
def cancel_invoices():
|
||||
purchase_invoices = frappe.get_all(
|
||||
@@ -997,6 +1081,88 @@ def create_payment_entry(**args):
|
||||
return pe
|
||||
|
||||
|
||||
def make_journal_entry_with_tax_withholding(
|
||||
party_type,
|
||||
party,
|
||||
voucher_type,
|
||||
amount,
|
||||
cost_center=None,
|
||||
posting_date=None,
|
||||
save=True,
|
||||
submit=False,
|
||||
):
|
||||
"""Helper function to create Journal Entry for tax withholding"""
|
||||
if not cost_center:
|
||||
cost_center = "_Test Cost Center - _TC"
|
||||
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.posting_date = posting_date or today()
|
||||
jv.company = "_Test Company"
|
||||
jv.voucher_type = voucher_type
|
||||
jv.multi_currency = 0
|
||||
|
||||
if party_type == "Supplier":
|
||||
# Debit Note: Expense Dr, Supplier Cr
|
||||
expense_account = "Stock Received But Not Billed - _TC"
|
||||
party_account = "Creditors - _TC"
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": expense_account,
|
||||
"cost_center": cost_center,
|
||||
"debit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": party_account,
|
||||
"party_type": party_type,
|
||||
"party": party,
|
||||
"cost_center": cost_center,
|
||||
"credit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
else: # Customer
|
||||
# Credit Note: Customer Dr, Income Cr
|
||||
party_account = "Debtors - _TC"
|
||||
income_account = "Sales - _TC"
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": party_account,
|
||||
"party_type": party_type,
|
||||
"party": party,
|
||||
"cost_center": cost_center,
|
||||
"debit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": income_account,
|
||||
"cost_center": cost_center,
|
||||
"credit_in_account_currency": amount,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
|
||||
if save or submit:
|
||||
jv.insert()
|
||||
|
||||
if submit:
|
||||
jv.submit()
|
||||
|
||||
return jv
|
||||
|
||||
|
||||
def create_records():
|
||||
# create a new suppliers
|
||||
for name in [
|
||||
|
||||
@@ -210,19 +210,20 @@ def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None, from_r
|
||||
for d in gl_map:
|
||||
cost_center = d.get("cost_center")
|
||||
|
||||
cost_center_allocation = get_cost_center_allocation_data(
|
||||
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
|
||||
)
|
||||
|
||||
if not cost_center_allocation:
|
||||
new_gl_map.append(d)
|
||||
continue
|
||||
|
||||
# Validate budget against main cost center
|
||||
if not from_repost:
|
||||
validate_expense_against_budget(
|
||||
d, expense_amount=flt(d.debit, precision) - flt(d.credit, precision)
|
||||
)
|
||||
|
||||
cost_center_allocation = get_cost_center_allocation_data(
|
||||
gl_map[0]["company"], gl_map[0]["posting_date"], cost_center
|
||||
)
|
||||
if not cost_center_allocation:
|
||||
new_gl_map.append(d)
|
||||
continue
|
||||
|
||||
if d.account == round_off_account:
|
||||
d.cost_center = cost_center_allocation[0][0]
|
||||
new_gl_map.append(d)
|
||||
@@ -426,7 +427,11 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
|
||||
gle.flags.notify_update = False
|
||||
gle.submit()
|
||||
|
||||
if not from_repost and gle.voucher_type != "Period Closing Voucher":
|
||||
if (
|
||||
not from_repost
|
||||
and gle.voucher_type != "Period Closing Voucher"
|
||||
and (gle.is_cancelled == 0 or gle.voucher_type == "Journal Entry")
|
||||
):
|
||||
validate_expense_against_budget(args)
|
||||
|
||||
|
||||
|
||||
@@ -1384,27 +1384,14 @@ class InitSQLProceduresForAR:
|
||||
amount_in_account_currency {_currency_type}) engine=memory;
|
||||
"""
|
||||
|
||||
# Function
|
||||
genkey_function_name = "ar_genkey"
|
||||
genkey_function_sql = f"""
|
||||
create function `{genkey_function_name}`(rec row type of `{_row_def_table_name}`, allocate bool) returns char(40)
|
||||
begin
|
||||
if allocate then
|
||||
return sha1(concat_ws(',', rec.account, rec.against_voucher_type, rec.against_voucher_no, rec.party));
|
||||
else
|
||||
return sha1(concat_ws(',', rec.account, rec.voucher_type, rec.voucher_no, rec.party));
|
||||
end if;
|
||||
end
|
||||
"""
|
||||
|
||||
# Procedures
|
||||
init_procedure_name = "ar_init_tmp_table"
|
||||
init_procedure_sql = f"""
|
||||
create procedure ar_init_tmp_table(in ple row type of `{_row_def_table_name}`)
|
||||
begin
|
||||
if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false))
|
||||
if not exists (select name from `{_voucher_balance_name}` where name = sha1(concat_ws(',', ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party)))
|
||||
then
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0);
|
||||
insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.against_voucher_type, ple.against_voucher_no, ple.party)), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0);
|
||||
end if;
|
||||
end;
|
||||
"""
|
||||
@@ -1446,16 +1433,13 @@ class InitSQLProceduresForAR:
|
||||
|
||||
end if;
|
||||
|
||||
insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
|
||||
insert into `{_voucher_balance_name}` values (sha1(concat_ws(',', ple.account, ple.voucher_type, ple.voucher_no, ple.party)), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0);
|
||||
end;
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
existing_procedures = frappe.db.get_routines()
|
||||
|
||||
if self.genkey_function_name not in existing_procedures:
|
||||
frappe.db.sql(self.genkey_function_sql)
|
||||
|
||||
if self.init_procedure_name not in existing_procedures:
|
||||
frappe.db.sql(self.init_procedure_sql)
|
||||
|
||||
|
||||
@@ -500,7 +500,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
|
||||
|
||||
immutable_ledger = frappe.get_single_value("Accounts Settings", "enable_immutable_ledger")
|
||||
|
||||
def update_value_in_dict(data, key, gle):
|
||||
def update_value_in_dict(data, key, gle, show_net_values=False):
|
||||
data[key].debit += gle.debit
|
||||
data[key].credit += gle.credit
|
||||
|
||||
@@ -511,10 +511,14 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
|
||||
data[key].debit_in_transaction_currency += gle.debit_in_transaction_currency
|
||||
data[key].credit_in_transaction_currency += gle.credit_in_transaction_currency
|
||||
|
||||
if filters.get("show_net_values_in_party_account") and account_type_map.get(data[key].account) in (
|
||||
"Receivable",
|
||||
"Payable",
|
||||
):
|
||||
if (
|
||||
filters.get("show_net_values_in_party_account")
|
||||
and account_type_map.get(data[key].account)
|
||||
in (
|
||||
"Receivable",
|
||||
"Payable",
|
||||
)
|
||||
) or show_net_values:
|
||||
net_value = data[key].debit - data[key].credit
|
||||
net_value_in_account_currency = (
|
||||
data[key].debit_in_account_currency - data[key].credit_in_account_currency
|
||||
@@ -548,11 +552,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map):
|
||||
|
||||
if gle.posting_date < from_date or (cstr(gle.is_opening) == "Yes" and not show_opening_entries):
|
||||
if not group_by_voucher_consolidated:
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "opening", gle)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "opening", gle, True)
|
||||
update_value_in_dict(gle_map[group_by_value].totals, "closing", gle, True)
|
||||
|
||||
update_value_in_dict(totals, "opening", gle)
|
||||
update_value_in_dict(totals, "closing", gle)
|
||||
update_value_in_dict(totals, "opening", gle, True)
|
||||
update_value_in_dict(totals, "closing", gle, True)
|
||||
|
||||
elif gle.posting_date <= to_date or (cstr(gle.is_opening) == "Yes" and show_opening_entries):
|
||||
if not group_by_voucher_consolidated:
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (
|
||||
@@ -41,16 +40,6 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
tax_doctype="Purchase Taxes and Charges",
|
||||
)
|
||||
|
||||
scrubbed_tax_fields = {}
|
||||
|
||||
for tax in tax_columns:
|
||||
scrubbed_tax_fields.update(
|
||||
{
|
||||
tax + " Rate": frappe.scrub(tax + " Rate"),
|
||||
tax + " Amount": frappe.scrub(tax + " Amount"),
|
||||
}
|
||||
)
|
||||
|
||||
po_pr_map = get_purchase_receipts_against_purchase_order(item_list)
|
||||
|
||||
data = []
|
||||
@@ -100,8 +89,8 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
for tax, details in itemised_tax.get(d.name, {}).items():
|
||||
row.update(
|
||||
{
|
||||
scrubbed_tax_fields[tax + " Rate"]: details.get("tax_rate", 0),
|
||||
scrubbed_tax_fields[tax + " Amount"]: details.get("tax_amount", 0),
|
||||
f"{tax}_rate": details.get("tax_rate", 0),
|
||||
f"{tax}_amount": details.get("tax_amount", 0),
|
||||
}
|
||||
)
|
||||
if details.get("is_other_charges"):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import functions as fn
|
||||
from frappe.utils import cstr, flt
|
||||
from frappe.utils import flt
|
||||
from frappe.utils.nestedset import get_descendants_of
|
||||
from frappe.utils.xlsxutils import handle_html
|
||||
|
||||
@@ -32,15 +32,6 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
return columns, [], None, None, None, 0
|
||||
|
||||
itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency)
|
||||
scrubbed_tax_fields = {}
|
||||
|
||||
for tax in tax_columns:
|
||||
scrubbed_tax_fields.update(
|
||||
{
|
||||
tax + " Rate": frappe.scrub(tax + " Rate"),
|
||||
tax + " Amount": frappe.scrub(tax + " Amount"),
|
||||
}
|
||||
)
|
||||
|
||||
mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list))
|
||||
so_dn_map = get_delivery_notes_against_sales_order(item_list)
|
||||
@@ -101,8 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
for tax, details in itemised_tax.get(d.name, {}).items():
|
||||
row.update(
|
||||
{
|
||||
scrubbed_tax_fields[tax + " Rate"]: details.get("tax_rate", 0),
|
||||
scrubbed_tax_fields[tax + " Amount"]: details.get("tax_amount", 0),
|
||||
f"{tax}_rate": details.get("tax_rate", 0),
|
||||
f"{tax}_amount": details.get("tax_amount", 0),
|
||||
}
|
||||
)
|
||||
if details.get("is_other_charges"):
|
||||
@@ -567,15 +558,24 @@ def get_tax_accounts(
|
||||
tax_details = query.run(as_dict=True)
|
||||
|
||||
precision = frappe.get_precision(tax_doctype, "tax_amount", currency=company_currency) or 2
|
||||
tax_columns = set()
|
||||
tax_columns = {}
|
||||
itemised_tax = {}
|
||||
scrubbed_description_map = {}
|
||||
|
||||
for row in tax_details:
|
||||
description = handle_html(row.description) or row.account_head
|
||||
scrubbed_description = scrubbed_description_map.get(description)
|
||||
if not scrubbed_description:
|
||||
scrubbed_description = frappe.scrub(description)
|
||||
scrubbed_description_map[description] = scrubbed_description
|
||||
|
||||
if scrubbed_description not in tax_columns and row.amount:
|
||||
# as description is text editor earlier and markup can break the column convention in reports
|
||||
tax_columns[scrubbed_description] = description
|
||||
|
||||
rate = "NA" if row.rate == 0 else row.rate
|
||||
tax_columns.add(description)
|
||||
itemised_tax.setdefault(row.item_row, {}).setdefault(
|
||||
description,
|
||||
scrubbed_description,
|
||||
frappe._dict(
|
||||
{
|
||||
"tax_rate": rate,
|
||||
@@ -585,14 +585,16 @@ def get_tax_accounts(
|
||||
),
|
||||
)
|
||||
|
||||
itemised_tax[row.item_row][description].tax_amount += flt(row.amount, precision)
|
||||
itemised_tax[row.item_row][scrubbed_description].tax_amount += flt(row.amount, precision)
|
||||
|
||||
tax_columns = sorted(tax_columns)
|
||||
for desc in tax_columns:
|
||||
tax_columns_list = list(tax_columns.keys())
|
||||
tax_columns_list.sort()
|
||||
for scrubbed_desc in tax_columns_list:
|
||||
desc = tax_columns[scrubbed_desc]
|
||||
columns.append(
|
||||
{
|
||||
"label": _(desc + " Rate"),
|
||||
"fieldname": frappe.scrub(desc + " Rate"),
|
||||
"fieldname": f"{scrubbed_desc}_rate",
|
||||
"fieldtype": "Float",
|
||||
"width": 100,
|
||||
}
|
||||
@@ -601,7 +603,7 @@ def get_tax_accounts(
|
||||
columns.append(
|
||||
{
|
||||
"label": _(desc + " Amount"),
|
||||
"fieldname": frappe.scrub(desc + " Amount"),
|
||||
"fieldname": f"{scrubbed_desc}_amount",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"width": 100,
|
||||
@@ -639,7 +641,7 @@ def get_tax_accounts(
|
||||
},
|
||||
]
|
||||
|
||||
return itemised_tax, tax_columns
|
||||
return itemised_tax, tax_columns_list
|
||||
|
||||
|
||||
def get_tax_details_query(doctype, tax_doctype):
|
||||
@@ -756,5 +758,5 @@ def add_sub_total_row(item, total_row_map, group_by_value, tax_columns):
|
||||
total_row["percent_gt"] += item["percent_gt"]
|
||||
|
||||
for tax in tax_columns:
|
||||
total_row.setdefault(frappe.scrub(tax + " Amount"), 0.0)
|
||||
total_row[frappe.scrub(tax + " Amount")] += flt(item[frappe.scrub(tax + " Amount")])
|
||||
total_row.setdefault(f"{tax}_amount", 0.0)
|
||||
total_row[f"{tax}_amount"] += flt(item[f"{tax}_amount"])
|
||||
|
||||
@@ -447,7 +447,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
|
||||
}
|
||||
|
||||
for key in value_fields:
|
||||
row[key] = flt(d.get(key, 0.0), 3)
|
||||
row[key] = flt(d.get(key, 0.0))
|
||||
|
||||
if abs(row[key]) >= get_zero_cutoff(company_currency):
|
||||
# ignore zero values
|
||||
|
||||
@@ -199,6 +199,8 @@ def get_balance_on(
|
||||
ignore_account_permission=False,
|
||||
account_type=None,
|
||||
start_date=None,
|
||||
finance_book=None,
|
||||
include_default_fb_balances=False,
|
||||
):
|
||||
if not account and frappe.form_dict.get("account"):
|
||||
account = frappe.form_dict.get("account")
|
||||
@@ -293,8 +295,31 @@ def get_balance_on(
|
||||
f"""gle.party_type = {frappe.db.escape(party_type)} and gle.party = {frappe.db.escape(party)} """
|
||||
)
|
||||
|
||||
default_finance_book = None
|
||||
if company:
|
||||
cond.append("""gle.company = %s """ % (frappe.db.escape(company)))
|
||||
default_finance_book = frappe.get_cached_value("Company", company, "default_finance_book")
|
||||
|
||||
if finance_book:
|
||||
if default_finance_book and include_default_fb_balances:
|
||||
cond.append(
|
||||
f"""(gle.finance_book IN (
|
||||
{frappe.db.escape(finance_book)},
|
||||
{frappe.db.escape(default_finance_book)}
|
||||
) OR gle.finance_book IS NULL
|
||||
)"""
|
||||
)
|
||||
else:
|
||||
cond.append(f"(gle.finance_book = {frappe.db.escape(finance_book)} OR gle.finance_book IS NULL)")
|
||||
|
||||
elif default_finance_book and include_default_fb_balances:
|
||||
# No finance book passed → fall back to default
|
||||
cond.append(
|
||||
f"""(
|
||||
gle.finance_book = {frappe.db.escape(default_finance_book)}
|
||||
OR gle.finance_book IS NULL
|
||||
)"""
|
||||
)
|
||||
|
||||
if account or (party_type and party) or account_type:
|
||||
precision = get_currency_precision()
|
||||
@@ -1333,7 +1358,7 @@ def get_children(doctype, parent, company, is_root=False, include_disabled=False
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_account_balances(accounts, company):
|
||||
def get_account_balances(accounts, company, finance_book=None, include_default_fb_balances=False):
|
||||
if isinstance(accounts, str):
|
||||
accounts = loads(accounts)
|
||||
|
||||
@@ -1344,9 +1369,25 @@ def get_account_balances(accounts, company):
|
||||
|
||||
for account in accounts:
|
||||
account["company_currency"] = company_currency
|
||||
account["balance"] = flt(get_balance_on(account["value"], in_account_currency=False, company=company))
|
||||
account["balance"] = flt(
|
||||
get_balance_on(
|
||||
account=account["value"],
|
||||
in_account_currency=False,
|
||||
company=company,
|
||||
finance_book=finance_book,
|
||||
include_default_fb_balances=include_default_fb_balances,
|
||||
)
|
||||
)
|
||||
|
||||
if account["account_currency"] and account["account_currency"] != company_currency:
|
||||
account["balance_in_account_currency"] = flt(get_balance_on(account["value"], company=company))
|
||||
account["balance_in_account_currency"] = flt(
|
||||
get_balance_on(
|
||||
account=account["value"],
|
||||
company=company,
|
||||
finance_book=finance_book,
|
||||
include_default_fb_balances=include_default_fb_balances,
|
||||
)
|
||||
)
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
@@ -80,6 +80,12 @@ frappe.ui.form.on("Asset", {
|
||||
}
|
||||
},
|
||||
|
||||
before_submit: function (frm) {
|
||||
if (frm.doc.is_composite_asset && !frm.has_active_capitalization) {
|
||||
frappe.throw(__("Please capitalize this asset before submitting."));
|
||||
}
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
frappe.ui.form.trigger("Asset", "is_existing_asset");
|
||||
frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1);
|
||||
@@ -196,9 +202,10 @@ frappe.ui.form.on("Asset", {
|
||||
asset: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
frm.has_active_capitalization = r.message;
|
||||
|
||||
if (!r.message) {
|
||||
$(".primary-action").prop("hidden", true);
|
||||
$(".form-message").text(__("Capitalize this asset to confirm"));
|
||||
$(".form-message").text(__("Capitalize this asset before submitting."));
|
||||
|
||||
frm.add_custom_button(__("Capitalize Asset"), function () {
|
||||
frm.trigger("create_asset_capitalization");
|
||||
@@ -270,8 +277,14 @@ frappe.ui.form.on("Asset", {
|
||||
const row = [
|
||||
sch["idx"],
|
||||
frappe.format(sch["schedule_date"], { fieldtype: "Date" }),
|
||||
frappe.format(sch["depreciation_amount"], { fieldtype: "Currency" }),
|
||||
frappe.format(sch["accumulated_depreciation_amount"], { fieldtype: "Currency" }),
|
||||
frappe.format(sch["depreciation_amount"], {
|
||||
fieldtype: "Currency",
|
||||
options: "Company:company:default_currency",
|
||||
}),
|
||||
frappe.format(sch["accumulated_depreciation_amount"], {
|
||||
fieldtype: "Currency",
|
||||
options: "Company:company:default_currency",
|
||||
}),
|
||||
sch["journal_entry"] || "",
|
||||
];
|
||||
|
||||
@@ -464,7 +477,6 @@ frappe.ui.form.on("Asset", {
|
||||
is_composite_asset: function (frm) {
|
||||
if (frm.doc.is_composite_asset) {
|
||||
frm.set_value("net_purchase_amount", 0);
|
||||
frm.set_df_property("net_purchase_amount", "read_only", 1);
|
||||
} else {
|
||||
frm.set_df_property("net_purchase_amount", "read_only", 0);
|
||||
}
|
||||
@@ -532,7 +544,6 @@ frappe.ui.form.on("Asset", {
|
||||
callback: function (r) {
|
||||
var doclist = frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
|
||||
$(".primary-action").prop("hidden", false);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -556,7 +556,8 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Purchase Amount",
|
||||
"mandatory_depends_on": "eval:(!doc.is_composite_asset || doc.docstatus==1)",
|
||||
"options": "Company:company:default_currency"
|
||||
"options": "Company:company:default_currency",
|
||||
"read_only_depends_on": "eval: doc.is_composite_asset"
|
||||
}
|
||||
],
|
||||
"idx": 72,
|
||||
@@ -600,7 +601,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2025-11-04 22:39:00.817405",
|
||||
"modified": "2025-12-18 16:36:40.904246",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
|
||||
@@ -142,21 +142,75 @@ class Asset(AccountsController):
|
||||
if self.split_from or not self.calculate_depreciation:
|
||||
return
|
||||
|
||||
schedules = []
|
||||
for row in self.get("finance_books"):
|
||||
self.validate_asset_finance_books(row)
|
||||
if not row.rate_of_depreciation:
|
||||
row.rate_of_depreciation = self.get_depreciation_rate(row, on_validate=True)
|
||||
created_schedules = []
|
||||
for fb_row in self.get("finance_books"):
|
||||
if not fb_row.rate_of_depreciation:
|
||||
fb_row.rate_of_depreciation = self.get_depreciation_rate(fb_row, on_validate=True)
|
||||
|
||||
schedule_doc = get_asset_depr_schedule_doc(self.name, "Draft", row.finance_book)
|
||||
if not schedule_doc:
|
||||
schedule_doc = frappe.new_doc("Asset Depreciation Schedule")
|
||||
schedule_doc.asset = self.name
|
||||
schedule_doc.create_depreciation_schedule(row)
|
||||
schedule_doc.save()
|
||||
schedules.append(schedule_doc.name)
|
||||
existing_schedule = get_asset_depr_schedule_doc(self.name, "Draft", fb_row.finance_book)
|
||||
|
||||
self.show_schedule_creation_message(schedules)
|
||||
if not existing_schedule:
|
||||
new_schedule = frappe.new_doc("Asset Depreciation Schedule")
|
||||
new_schedule.asset = self.name
|
||||
new_schedule.create_depreciation_schedule(fb_row)
|
||||
new_schedule.save()
|
||||
created_schedules.append(new_schedule.name)
|
||||
continue
|
||||
|
||||
self.evaluate_and_recreate_depreciation_schedule(existing_schedule, fb_row)
|
||||
created_schedules.append(existing_schedule.name)
|
||||
|
||||
self.show_schedule_creation_message(created_schedules)
|
||||
|
||||
def evaluate_and_recreate_depreciation_schedule(self, existing_doc, fb_row):
|
||||
"""Determine if depreciation schedule needs to be regenerated and recreate if necessary"""
|
||||
|
||||
asset_details_changed = self.has_asset_details_changed(existing_doc)
|
||||
depreciation_settings_changed = self.has_depreciation_settings_changed(existing_doc, fb_row)
|
||||
if self.should_regenerate_depreciation_schedule(
|
||||
existing_doc, asset_details_changed, depreciation_settings_changed
|
||||
):
|
||||
existing_doc.create_depreciation_schedule(fb_row)
|
||||
existing_doc.save()
|
||||
|
||||
def has_asset_details_changed(self, existing_doc):
|
||||
"""Check if core asset details that affect depreciation have changed"""
|
||||
return (
|
||||
self.net_purchase_amount != existing_doc.net_purchase_amount
|
||||
or self.opening_accumulated_depreciation != existing_doc.opening_accumulated_depreciation
|
||||
or self.opening_number_of_booked_depreciations
|
||||
!= existing_doc.opening_number_of_booked_depreciations
|
||||
)
|
||||
|
||||
def has_depreciation_settings_changed(self, existing_doc, fb_row):
|
||||
"""Check if depreciation calculation settings have changed"""
|
||||
|
||||
if not existing_doc.get("depreciation_schedule") or fb_row.depreciation_method != "Manual":
|
||||
return True
|
||||
|
||||
return (
|
||||
fb_row.depreciation_method != existing_doc.depreciation_method
|
||||
or fb_row.total_number_of_depreciations != existing_doc.total_number_of_depreciations
|
||||
or fb_row.frequency_of_depreciation != existing_doc.frequency_of_depreciation
|
||||
or getdate(fb_row.depreciation_start_date)
|
||||
!= existing_doc.get("depreciation_schedule")[0].schedule_date
|
||||
or fb_row.expected_value_after_useful_life != existing_doc.expected_value_after_useful_life
|
||||
)
|
||||
|
||||
def should_regenerate_depreciation_schedule(
|
||||
self, existing_doc, asset_details_changed, depreciation_settings_changed
|
||||
):
|
||||
"""Check all conditions to determine if schedule regeneration is required"""
|
||||
|
||||
# Schedule doesn't exist yet
|
||||
if not existing_doc.get("depreciation_schedule"):
|
||||
return True
|
||||
|
||||
# Either asset details or depreciation settings have changed
|
||||
if asset_details_changed or depreciation_settings_changed:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_depr_rate_and_value_after_depreciation(self):
|
||||
if self.split_from:
|
||||
@@ -188,6 +242,10 @@ class Asset(AccountsController):
|
||||
self.validate_expected_value_after_useful_life()
|
||||
self.set_total_booked_depreciations()
|
||||
|
||||
def before_submit(self):
|
||||
if self.is_composite_asset and not has_active_capitalization(self.name):
|
||||
frappe.throw(_("Please capitalize this asset before submitting."))
|
||||
|
||||
def on_submit(self):
|
||||
self.validate_in_use_date()
|
||||
self.make_asset_movement()
|
||||
@@ -530,6 +588,7 @@ class Asset(AccountsController):
|
||||
|
||||
def set_depreciation_rate(self):
|
||||
for d in self.get("finance_books"):
|
||||
self.validate_asset_finance_books(d)
|
||||
d.rate_of_depreciation = flt(
|
||||
self.get_depreciation_rate(d, on_validate=True), d.precision("rate_of_depreciation")
|
||||
)
|
||||
@@ -538,6 +597,9 @@ class Asset(AccountsController):
|
||||
row.expected_value_after_useful_life = flt(
|
||||
row.expected_value_after_useful_life, self.precision("net_purchase_amount")
|
||||
)
|
||||
|
||||
if flt(row.expected_value_after_useful_life) < 0:
|
||||
frappe.throw(_("Row {0}: Expected Value After Useful Life cannot be negative").format(row.idx))
|
||||
if flt(row.expected_value_after_useful_life) >= flt(self.net_purchase_amount):
|
||||
frappe.throw(
|
||||
_("Row {0}: Expected Value After Useful Life must be less than Net Purchase Amount").format(
|
||||
@@ -548,6 +610,7 @@ class Asset(AccountsController):
|
||||
if not row.depreciation_start_date:
|
||||
row.depreciation_start_date = get_last_day(self.available_for_use_date)
|
||||
self.validate_depreciation_start_date(row)
|
||||
self.validate_total_number_of_depreciations_and_frequency(row)
|
||||
|
||||
if not self.is_existing_asset:
|
||||
self.opening_accumulated_depreciation = 0
|
||||
@@ -584,6 +647,15 @@ class Asset(AccountsController):
|
||||
title=_("Invalid Schedule"),
|
||||
)
|
||||
|
||||
def validate_total_number_of_depreciations_and_frequency(self, row):
|
||||
if row.total_number_of_depreciations <= 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Total Number of Depreciations must be greater than zero").format(row.idx)
|
||||
)
|
||||
|
||||
if row.frequency_of_depreciation <= 0:
|
||||
frappe.throw(_("Row #{0}: Frequency of Depreciation must be greater than zero").format(row.idx))
|
||||
|
||||
def validate_depreciation_start_date(self, row):
|
||||
if row.depreciation_start_date:
|
||||
if getdate(row.depreciation_start_date) < getdate(self.purchase_date):
|
||||
|
||||
@@ -194,6 +194,13 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
|
||||
}
|
||||
}
|
||||
|
||||
serial_and_batch_bundle(doc, cdt, cdn) {
|
||||
var row = frappe.get_doc(cdt, cdn);
|
||||
if (cdt === "Asset Capitalization Stock Item") {
|
||||
this.get_warehouse_details(row);
|
||||
}
|
||||
}
|
||||
|
||||
asset(doc, cdt, cdn) {
|
||||
var row = frappe.get_doc(cdt, cdn);
|
||||
if (cdt === "Asset Capitalization Asset Item") {
|
||||
@@ -407,6 +414,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s
|
||||
voucher_type: me.frm.doc.doctype,
|
||||
voucher_no: me.frm.doc.name,
|
||||
allow_zero_valuation: 1,
|
||||
serial_and_batch_bundle: item.serial_and_batch_bundle,
|
||||
},
|
||||
},
|
||||
callback: function (r) {
|
||||
|
||||
@@ -352,6 +352,7 @@ class AssetCapitalization(StockController):
|
||||
"voucher_no": self.name,
|
||||
"company": self.company,
|
||||
"allow_zero_valuation": cint(item.get("allow_zero_valuation_rate")),
|
||||
"serial_and_batch_bundle": item.serial_and_batch_bundle,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -723,6 +724,7 @@ def get_consumed_stock_item_details(ctx: ItemDetailsCtx):
|
||||
"company": ctx.company,
|
||||
"serial_no": ctx.serial_no,
|
||||
"batch_no": ctx.batch_no,
|
||||
"serial_and_batch_bundle": ctx.serial_and_batch_bundle,
|
||||
}
|
||||
)
|
||||
out.update(get_warehouse_details(incoming_rate_args))
|
||||
|
||||
@@ -117,25 +117,52 @@ frappe.ui.form.on("Asset Repair", {
|
||||
frm.refresh_field("stock_items");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
purchase_invoice: function (frm) {
|
||||
if (frm.doc.purchase_invoice) {
|
||||
frappe.call({
|
||||
method: "frappe.client.get_value",
|
||||
args: {
|
||||
doctype: "Purchase Invoice",
|
||||
fieldname: "base_net_total",
|
||||
filters: { name: frm.doc.purchase_invoice },
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("repair_cost", r.message.base_net_total);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
frm.set_value("repair_cost", 0);
|
||||
frappe.ui.form.on("Asset Repair Purchase Invoice", {
|
||||
purchase_invoice: function (frm, cdt, cdn) {
|
||||
frappe.model.set_value(cdt, cdn, {
|
||||
expense_account: "",
|
||||
repair_cost: 0,
|
||||
});
|
||||
},
|
||||
|
||||
expense_account: function (frm, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
|
||||
if (!row.purchase_invoice || !row.expense_account) {
|
||||
frappe.model.set_value(cdt, cdn, "repair_cost", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_unallocated_repair_cost",
|
||||
args: {
|
||||
purchase_invoice: row.purchase_invoice,
|
||||
expense_account: row.expense_account,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message !== undefined) {
|
||||
if (r.message > 0) {
|
||||
frappe.model.set_value(cdt, cdn, "repair_cost", r.message);
|
||||
} else {
|
||||
frappe.model.set_value(cdt, cdn, "repair_cost", 0);
|
||||
let pi_link = frappe.utils.get_form_link(
|
||||
"Purchase Invoice",
|
||||
row.purchase_invoice,
|
||||
true
|
||||
);
|
||||
frappe.msgprint({
|
||||
message: __(
|
||||
"Row {0}: The entire expense amount for account {1} in {2} has already been allocated.",
|
||||
[row.idx, row.expense_account.bold(), pi_link]
|
||||
),
|
||||
indicator: "orange",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
show_general_ledger: (frm) => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form, getdate, time_diff_in_hours
|
||||
|
||||
import erpnext
|
||||
@@ -14,7 +15,6 @@ from erpnext.accounts.general_ledger import make_gl_entries
|
||||
from erpnext.assets.doctype.asset.asset import get_asset_account
|
||||
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
reschedule_depreciation,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
@@ -59,7 +59,7 @@ class AssetRepair(AccountsController):
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.asset_doc = frappe.get_doc("Asset", self.asset)
|
||||
self.asset_doc = frappe.get_lazy_doc("Asset", self.asset)
|
||||
self.validate_asset()
|
||||
self.validate_dates()
|
||||
self.validate_purchase_invoices()
|
||||
@@ -84,60 +84,91 @@ class AssetRepair(AccountsController):
|
||||
)
|
||||
|
||||
def validate_purchase_invoices(self):
|
||||
self.validate_duplicate_purchase_invoices()
|
||||
self.validate_purchase_invoice_status()
|
||||
|
||||
for d in self.invoices:
|
||||
self.validate_purchase_invoice_status(d.purchase_invoice)
|
||||
invoice_items = self.get_invoice_items(d.purchase_invoice)
|
||||
self.validate_service_purchase_invoice(d.purchase_invoice, invoice_items)
|
||||
self.validate_expense_account(d, invoice_items)
|
||||
self.validate_purchase_invoice_repair_cost(d, invoice_items)
|
||||
self.validate_expense_account(d)
|
||||
self.validate_purchase_invoice_repair_cost(d)
|
||||
|
||||
def validate_purchase_invoice_status(self, purchase_invoice):
|
||||
docstatus = frappe.db.get_value("Purchase Invoice", purchase_invoice, "docstatus")
|
||||
if docstatus == 0:
|
||||
frappe.throw(
|
||||
_("{0} is still in Draft. Please submit it before saving the Asset Repair.").format(
|
||||
get_link_to_form("Purchase Invoice", purchase_invoice)
|
||||
)
|
||||
def validate_duplicate_purchase_invoices(self):
|
||||
# account wise duplicate check
|
||||
purchase_invoices = set()
|
||||
duplicates = []
|
||||
for row in self.invoices:
|
||||
key = (row.purchase_invoice, row.expense_account)
|
||||
if key in purchase_invoices:
|
||||
duplicates.append((row.idx, row.purchase_invoice, row.expense_account))
|
||||
else:
|
||||
purchase_invoices.add(key)
|
||||
|
||||
if duplicates:
|
||||
duplicate_links = "".join(
|
||||
[
|
||||
f"<li>{_('Row #{0}:').format(idx)} {get_link_to_form('Purchase Invoice', pi)} - {frappe.bold(account)}</li>"
|
||||
for idx, pi, account in duplicates
|
||||
]
|
||||
)
|
||||
msg = _("The following rows are duplicates:") + f"<br><ul>{duplicate_links}</ul>"
|
||||
frappe.throw(msg)
|
||||
|
||||
def get_invoice_items(self, pi):
|
||||
invoice_items = frappe.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": pi},
|
||||
fields=["item_code", "expense_account", "base_net_amount"],
|
||||
def validate_purchase_invoice_status(self):
|
||||
pi_names = [row.purchase_invoice for row in self.invoices]
|
||||
docstatus = frappe._dict(
|
||||
frappe.db.get_all(
|
||||
"Purchase Invoice",
|
||||
filters={"name": ["in", pi_names]},
|
||||
fields=["name", "docstatus"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
return invoice_items
|
||||
invalid_invoice = []
|
||||
for row in self.invoices:
|
||||
if docstatus.get(row.purchase_invoice) != 1:
|
||||
invalid_invoice.append((row.idx, row.purchase_invoice))
|
||||
|
||||
def validate_service_purchase_invoice(self, purchase_invoice, invoice_items):
|
||||
service_item_exists = False
|
||||
for item in invoice_items:
|
||||
if frappe.db.get_value("Item", item.item_code, "is_stock_item") == 0:
|
||||
service_item_exists = True
|
||||
break
|
||||
if invalid_invoice:
|
||||
invoice_links = "".join(
|
||||
[
|
||||
f"<li>{_('Row #{0}:').format(idx)} {get_link_to_form('Purchase Invoice', pi)}</li>"
|
||||
for idx, pi in invalid_invoice
|
||||
]
|
||||
)
|
||||
msg = _("The following Purchase Invoices are not submitted:") + f"<br><ul>{invoice_links}</ul>"
|
||||
frappe.throw(msg)
|
||||
|
||||
if not service_item_exists:
|
||||
def validate_expense_account(self, row):
|
||||
"""Validate that the expense account exists in the purchase invoice for non-stock items."""
|
||||
valid_accounts = _get_expense_accounts_for_purchase_invoice(row.purchase_invoice)
|
||||
if row.expense_account not in valid_accounts:
|
||||
frappe.throw(
|
||||
_("Service item not present in Purchase Invoice {0}").format(
|
||||
get_link_to_form("Purchase Invoice", purchase_invoice)
|
||||
_(
|
||||
"Row #{0}: Expense account {1} is not valid for Purchase Invoice {2}. "
|
||||
"Only expense accounts from non-stock items are allowed."
|
||||
).format(
|
||||
row.idx,
|
||||
frappe.bold(row.expense_account),
|
||||
get_link_to_form("Purchase Invoice", row.purchase_invoice),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_expense_account(self, row, invoice_items):
|
||||
pi_expense_accounts = set([item.expense_account for item in invoice_items])
|
||||
if row.expense_account not in pi_expense_accounts:
|
||||
frappe.throw(
|
||||
_("Expense account {0} not present in Purchase Invoice {1}").format(
|
||||
row.expense_account, get_link_to_form("Purchase Invoice", row.purchase_invoice)
|
||||
)
|
||||
)
|
||||
def validate_purchase_invoice_repair_cost(self, row):
|
||||
"""Validate that repair cost doesn't exceed available amount."""
|
||||
available_amount = get_unallocated_repair_cost(
|
||||
row.purchase_invoice, row.expense_account, exclude_asset_repair=self.name
|
||||
)
|
||||
|
||||
def validate_purchase_invoice_repair_cost(self, row, invoice_items):
|
||||
pi_net_total = sum([flt(item.base_net_amount) for item in invoice_items])
|
||||
if flt(row.repair_cost) > pi_net_total:
|
||||
if flt(row.repair_cost) > available_amount:
|
||||
frappe.throw(
|
||||
_("Repair cost cannot be greater than purchase invoice base net total {0}").format(
|
||||
pi_net_total
|
||||
_(
|
||||
"Row #{0}: Repair cost {1} exceeds available amount {2} for Purchase Invoice {3} and Account {4}"
|
||||
).format(
|
||||
row.idx,
|
||||
frappe.bold(frappe.format_value(row.repair_cost, {"fieldtype": "Currency"})),
|
||||
frappe.bold(frappe.format_value(available_amount, {"fieldtype": "Currency"})),
|
||||
get_link_to_form("Purchase Invoice", row.purchase_invoice),
|
||||
frappe.bold(row.expense_account),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -199,7 +230,7 @@ class AssetRepair(AccountsController):
|
||||
self.cancel_sabb()
|
||||
|
||||
def after_delete(self):
|
||||
frappe.get_doc("Asset", self.asset).set_status()
|
||||
frappe.get_lazy_doc("Asset", self.asset).set_status()
|
||||
|
||||
def check_repair_status(self):
|
||||
if self.repair_status == "Pending" and self.docstatus == 1:
|
||||
@@ -341,7 +372,10 @@ class AssetRepair(AccountsController):
|
||||
return
|
||||
|
||||
# creating GL Entries for each row in Stock Items based on the Stock Entry created for it
|
||||
stock_entry = frappe.get_doc("Stock Entry", {"asset_repair": self.name})
|
||||
stock_entry_name = frappe.db.get_value("Stock Entry", {"asset_repair": self.name}, "name")
|
||||
stock_entry_items = frappe.get_all(
|
||||
"Stock Entry Detail", filters={"parent": stock_entry_name}, fields=["expense_account", "amount"]
|
||||
)
|
||||
|
||||
default_expense_account = None
|
||||
if not erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
@@ -351,7 +385,7 @@ class AssetRepair(AccountsController):
|
||||
if not default_expense_account:
|
||||
frappe.throw(_("Please set default Expense Account in Company {0}").format(self.company))
|
||||
|
||||
for item in stock_entry.items:
|
||||
for item in stock_entry_items:
|
||||
if flt(item.amount) > 0:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
@@ -382,7 +416,7 @@ class AssetRepair(AccountsController):
|
||||
"cost_center": self.cost_center,
|
||||
"posting_date": self.completion_date,
|
||||
"against_voucher_type": "Stock Entry",
|
||||
"against_voucher": stock_entry.name,
|
||||
"against_voucher": stock_entry_name,
|
||||
"company": self.company,
|
||||
},
|
||||
item=self,
|
||||
@@ -420,33 +454,152 @@ def get_downtime(failure_date, completion_date):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_purchase_invoice(doctype, txt, searchfield, start, page_len, filters):
|
||||
PurchaseInvoice = DocType("Purchase Invoice")
|
||||
PurchaseInvoiceItem = DocType("Purchase Invoice Item")
|
||||
Item = DocType("Item")
|
||||
"""
|
||||
Get Purchase Invoices that have expense accounts for non-stock items.
|
||||
Only returns invoices with at least one non-stock, non-fixed-asset item with an expense account.
|
||||
"""
|
||||
pi = DocType("Purchase Invoice")
|
||||
pi_item = DocType("Purchase Invoice Item")
|
||||
item = DocType("Item")
|
||||
|
||||
return (
|
||||
frappe.qb.from_(PurchaseInvoice)
|
||||
.join(PurchaseInvoiceItem)
|
||||
.on(PurchaseInvoiceItem.parent == PurchaseInvoice.name)
|
||||
.join(Item)
|
||||
.on(Item.name == PurchaseInvoiceItem.item_code)
|
||||
.select(PurchaseInvoice.name)
|
||||
query = (
|
||||
frappe.qb.from_(pi)
|
||||
.join(pi_item)
|
||||
.on(pi_item.parent == pi.name)
|
||||
.left_join(item)
|
||||
.on(item.name == pi_item.item_code)
|
||||
.select(pi.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(Item.is_stock_item == 0)
|
||||
& (Item.is_fixed_asset == 0)
|
||||
& (PurchaseInvoice.company == filters.get("company"))
|
||||
& (PurchaseInvoice.docstatus == 1)
|
||||
(pi.company == filters.get("company"))
|
||||
& (pi.docstatus == 1)
|
||||
& (pi_item.is_fixed_asset == 0)
|
||||
& (pi_item.expense_account.isnotnull())
|
||||
& (pi_item.expense_account != "")
|
||||
& ((pi_item.item_code.isnull()) | (item.is_stock_item == 0))
|
||||
)
|
||||
).run(as_list=1)
|
||||
)
|
||||
|
||||
if txt:
|
||||
query = query.where(pi.name.like(f"%{txt}%"))
|
||||
|
||||
return query.run(as_list=1)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_expense_accounts(doctype, txt, searchfield, start, page_len, filters):
|
||||
PurchaseInvoiceItem = DocType("Purchase Invoice Item")
|
||||
return (
|
||||
frappe.qb.from_(PurchaseInvoiceItem)
|
||||
.select(PurchaseInvoiceItem.expense_account)
|
||||
.distinct()
|
||||
.where(PurchaseInvoiceItem.parent == filters.get("purchase_invoice"))
|
||||
).run(as_list=1)
|
||||
"""
|
||||
Get expense accounts for non-stock (service) items from the purchase invoice.
|
||||
Used as a query function for link fields.
|
||||
"""
|
||||
purchase_invoice = filters.get("purchase_invoice")
|
||||
if not purchase_invoice:
|
||||
return []
|
||||
|
||||
expense_accounts = _get_expense_accounts_for_purchase_invoice(purchase_invoice)
|
||||
|
||||
return [[account] for account in expense_accounts]
|
||||
|
||||
|
||||
def _get_expense_accounts_for_purchase_invoice(purchase_invoice: str) -> list[str]:
|
||||
"""
|
||||
Get expense accounts for non-stock items from the purchase invoice.
|
||||
"""
|
||||
pi_items = frappe.db.get_all(
|
||||
"Purchase Invoice Item",
|
||||
filters={"parent": purchase_invoice},
|
||||
fields=["item_code", "expense_account", "is_fixed_asset"],
|
||||
)
|
||||
|
||||
if not pi_items:
|
||||
return []
|
||||
|
||||
# Get list of stock item codes from the invoice
|
||||
item_codes = {item.item_code for item in pi_items if item.item_code}
|
||||
stock_items = set()
|
||||
if item_codes:
|
||||
stock_items = set(
|
||||
frappe.db.get_all(
|
||||
"Item", filters={"name": ["in", list(item_codes)], "is_stock_item": 1}, pluck="name"
|
||||
)
|
||||
)
|
||||
|
||||
expense_accounts = set()
|
||||
|
||||
for item in pi_items:
|
||||
# Skip stock items - they use warehouse accounts
|
||||
if item.item_code and item.item_code in stock_items:
|
||||
continue
|
||||
|
||||
# Skip fixed assets - they use asset accounts
|
||||
if item.is_fixed_asset:
|
||||
continue
|
||||
|
||||
# Use expense account from Purchase Invoice Item
|
||||
if item.expense_account:
|
||||
expense_accounts.add(item.expense_account)
|
||||
|
||||
return list(expense_accounts)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_unallocated_repair_cost(
|
||||
purchase_invoice: str, expense_account: str, exclude_asset_repair: str | None = None
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the unused repair cost for a purchase invoice and expense account.
|
||||
"""
|
||||
if not purchase_invoice or not expense_account:
|
||||
return 0.0
|
||||
|
||||
frappe.has_permission("Purchase Invoice", "read", purchase_invoice, throw=True)
|
||||
|
||||
used_amount = get_allocated_repair_cost(purchase_invoice, expense_account, exclude_asset_repair)
|
||||
total_amount = get_total_expense_amount(purchase_invoice, expense_account)
|
||||
|
||||
return flt(total_amount - used_amount)
|
||||
|
||||
|
||||
def get_allocated_repair_cost(
|
||||
purchase_invoice: str, expense_account: str, exclude_asset_repair: str | None = None
|
||||
) -> float:
|
||||
"""Get the total repair cost already allocated from submitted Asset Repairs."""
|
||||
asset_repair_pi = DocType("Asset Repair Purchase Invoice")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(asset_repair_pi)
|
||||
.select(Sum(asset_repair_pi.repair_cost).as_("total"))
|
||||
.where(
|
||||
(asset_repair_pi.purchase_invoice == purchase_invoice)
|
||||
& (asset_repair_pi.expense_account == expense_account)
|
||||
& (asset_repair_pi.docstatus == 1)
|
||||
)
|
||||
)
|
||||
|
||||
if exclude_asset_repair:
|
||||
query = query.where(asset_repair_pi.parent != exclude_asset_repair)
|
||||
|
||||
result = query.run(as_dict=True)
|
||||
|
||||
return flt(result[0].total) if result else 0.0
|
||||
|
||||
|
||||
def get_total_expense_amount(purchase_invoice: str, expense_account: str) -> float:
|
||||
"""Get the total expense amount from GL entries for a purchase invoice and account."""
|
||||
gl_entry = DocType("GL Entry")
|
||||
|
||||
result = (
|
||||
frappe.qb.from_(gl_entry)
|
||||
.select((Sum(gl_entry.debit) - Sum(gl_entry.credit)).as_("total"))
|
||||
.where(
|
||||
(gl_entry.voucher_type == "Purchase Invoice")
|
||||
& (gl_entry.voucher_no == purchase_invoice)
|
||||
& (gl_entry.account == expense_account)
|
||||
& (gl_entry.is_cancelled == 0)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
return flt(result[0].total) if result else 0.0
|
||||
|
||||
@@ -175,6 +175,39 @@ class TestAssetRepair(IntegrationTestCase):
|
||||
)
|
||||
self.assertTrue(asset_repair.invoices)
|
||||
|
||||
def test_repair_cost_exceeds_available_amount(self):
|
||||
"""Test that repair cost cannot exceed available amount from Purchase Invoice."""
|
||||
asset_repair1 = create_asset_repair(
|
||||
capitalize_repair_cost=1,
|
||||
item="_Test Non Stock Item",
|
||||
submit=1,
|
||||
)
|
||||
|
||||
pi_name = asset_repair1.invoices[0].purchase_invoice
|
||||
expense_account = asset_repair1.invoices[0].expense_account
|
||||
|
||||
asset_repair2 = frappe.new_doc("Asset Repair")
|
||||
asset_repair2.update(
|
||||
{
|
||||
"asset": asset_repair1.asset,
|
||||
"asset_name": asset_repair1.asset_name,
|
||||
"failure_date": nowdate(),
|
||||
"description": "Second Repair",
|
||||
"company": asset_repair1.company,
|
||||
"capitalize_repair_cost": 1,
|
||||
}
|
||||
)
|
||||
asset_repair2.append(
|
||||
"invoices",
|
||||
{
|
||||
"purchase_invoice": pi_name,
|
||||
"expense_account": expense_account,
|
||||
"repair_cost": 10, # PI already fully used, so this should fail
|
||||
},
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, asset_repair2.save)
|
||||
|
||||
def test_gl_entries_with_perpetual_inventory(self):
|
||||
set_depreciation_settings_in_company(company="_Test Company with perpetual inventory")
|
||||
|
||||
|
||||
@@ -929,6 +929,7 @@ def get_list_context(context=None):
|
||||
"show_search": True,
|
||||
"no_breadcrumbs": True,
|
||||
"title": _("Purchase Orders"),
|
||||
"list_template": "templates/includes/list/list.html",
|
||||
}
|
||||
)
|
||||
return list_context
|
||||
|
||||
@@ -51,11 +51,11 @@ frappe.listview_settings["Purchase Order"] = {
|
||||
onload: function (listview) {
|
||||
var method = "erpnext.buying.doctype.purchase_order.purchase_order.close_or_unclose_purchase_orders";
|
||||
|
||||
listview.page.add_menu_item(__("Close"), function () {
|
||||
listview.page.add_action_item(__("Close"), function () {
|
||||
listview.call_for_selected_items(method, { status: "Closed" });
|
||||
});
|
||||
|
||||
listview.page.add_menu_item(__("Reopen"), function () {
|
||||
listview.page.add_action_item(__("Reopen"), function () {
|
||||
listview.call_for_selected_items(method, { status: "Submitted" });
|
||||
});
|
||||
|
||||
|
||||
@@ -551,7 +551,10 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
|
||||
} else if (args.supplier_group) {
|
||||
frappe.db
|
||||
.get_list("Supplier", {
|
||||
filters: { supplier_group: args.supplier_group },
|
||||
filters: {
|
||||
supplier_group: args.supplier_group,
|
||||
disabled: 0,
|
||||
},
|
||||
limit: 100,
|
||||
order_by: "name",
|
||||
})
|
||||
|
||||
@@ -386,6 +386,7 @@ def get_list_context(context=None):
|
||||
"show_search": True,
|
||||
"no_breadcrumbs": True,
|
||||
"title": _("Request for Quotation"),
|
||||
"list_template": "templates/includes/list/list.html",
|
||||
}
|
||||
)
|
||||
return list_context
|
||||
|
||||
@@ -32,6 +32,13 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
|
||||
this.make_purchase_order.bind(this),
|
||||
__("Create")
|
||||
);
|
||||
this.frm.add_custom_button(__("Update Items"), () => {
|
||||
erpnext.utils.update_child_items({
|
||||
frm: this.frm,
|
||||
child_docname: "items",
|
||||
cannot_add_row: false,
|
||||
});
|
||||
});
|
||||
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
|
||||
this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create"));
|
||||
} else if (this.frm.doc.docstatus === 0) {
|
||||
|
||||
@@ -232,6 +232,7 @@ def get_list_context(context=None):
|
||||
"show_search": True,
|
||||
"no_breadcrumbs": True,
|
||||
"title": _("Supplier Quotation"),
|
||||
"list_template": "templates/includes/list/list.html",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -347,3 +348,15 @@ def set_expired_status():
|
||||
""",
|
||||
(nowdate()),
|
||||
)
|
||||
|
||||
|
||||
def get_purchased_items(supplier_quotation: str):
|
||||
return frappe._dict(
|
||||
frappe.get_all(
|
||||
"Purchase Order Item",
|
||||
filters={"supplier_quotation": supplier_quotation, "docstatus": 1},
|
||||
fields=["supplier_quotation_item", {"SUM": "qty"}],
|
||||
group_by="supplier_quotation_item",
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,15 +2,115 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase, change_settings
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
|
||||
|
||||
|
||||
class TestPurchaseOrder(IntegrationTestCase):
|
||||
def test_update_child_supplier_quotation_add_item(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.submit()
|
||||
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": sq.items[0].item_code,
|
||||
"rate": sq.items[0].rate,
|
||||
"qty": 5,
|
||||
"docname": sq.items[0].name,
|
||||
},
|
||||
{
|
||||
"item_code": "_Test Item 2",
|
||||
"rate": 300,
|
||||
"qty": 3,
|
||||
},
|
||||
]
|
||||
)
|
||||
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
|
||||
sq.reload()
|
||||
self.assertEqual(sq.get("items")[0].qty, 5)
|
||||
self.assertEqual(sq.get("items")[1].rate, 300)
|
||||
|
||||
def test_update_supplier_quotation_child_rate_disallow(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.submit()
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": sq.items[0].item_code,
|
||||
"rate": 300,
|
||||
"qty": sq.items[0].qty,
|
||||
"docname": sq.items[0].name,
|
||||
},
|
||||
]
|
||||
)
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
|
||||
)
|
||||
|
||||
def test_update_supplier_quotation_child_remove_item(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.submit()
|
||||
po = make_purchase_order(sq.name)
|
||||
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": sq.items[0].item_code,
|
||||
"rate": sq.items[0].rate,
|
||||
"qty": sq.items[0].qty,
|
||||
"docname": sq.items[0].name,
|
||||
},
|
||||
{
|
||||
"item_code": "_Test Item 2",
|
||||
"rate": 300,
|
||||
"qty": 3,
|
||||
},
|
||||
]
|
||||
)
|
||||
po.get("items")[0].schedule_date = add_days(today(), 1)
|
||||
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
|
||||
po.submit()
|
||||
sq.reload()
|
||||
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": "_Test Item 2",
|
||||
"rate": 300,
|
||||
"qty": 3,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
frappe.db.savepoint("before_cancel")
|
||||
# check if item having purchase order can be removed
|
||||
self.assertRaises(
|
||||
frappe.LinkExistsError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name
|
||||
)
|
||||
frappe.db.rollback(save_point="before_cancel")
|
||||
|
||||
trans_item = json.dumps(
|
||||
[
|
||||
{
|
||||
"item_code": sq.items[0].item_code,
|
||||
"rate": sq.items[0].rate,
|
||||
"qty": sq.items[0].qty,
|
||||
"docname": sq.items[0].name,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
update_child_qty_rate("Supplier Quotation", trans_item, sq.name)
|
||||
sq.reload()
|
||||
self.assertEqual(len(sq.get("items")), 1)
|
||||
|
||||
def test_supplier_quotation_qty(self):
|
||||
sq = frappe.copy_doc(self.globalTestRecords["Supplier Quotation"][0])
|
||||
sq.items[0].qty = 0
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"aggregate_function_based_on": "base_rounded_total",
|
||||
"creation": "2025-12-19 16:10:00.369804",
|
||||
"currency": "",
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Purchase Order",
|
||||
"dynamic_filters_json": "[[\"Purchase Order\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Purchase Order\",\"transaction_date\",\"Timespan\",\"this quarter\"],[\"Purchase Order\",\"docstatus\",\"=\",\"1\"],[\"Purchase Order\",\"is_subcontracted\",\"=\",0]]",
|
||||
"function": "Average",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Average Order Values",
|
||||
"modified": "2025-12-19 16:57:02.546572",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Average Order Values",
|
||||
"owner": "Administrator",
|
||||
"parent_document_type": "",
|
||||
"report_function": "Sum",
|
||||
"show_full_number": 0,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Weekly",
|
||||
"type": "Document Type"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"aggregate_function_based_on": "",
|
||||
"creation": "2025-12-19 16:08:48.262514",
|
||||
"currency": "",
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Purchase Order",
|
||||
"dynamic_filters_json": "[[\"Purchase Order\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Purchase Order\",\"transaction_date\",\"Timespan\",\"this quarter\"],[\"Purchase Order\",\"docstatus\",\"=\",\"1\"],[\"Purchase Order\",\"is_subcontracted\",\"=\",0]]",
|
||||
"function": "Count",
|
||||
"idx": 1,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Purchase Orders Count",
|
||||
"modified": "2025-12-19 16:57:12.960310",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Orders Count",
|
||||
"owner": "Administrator",
|
||||
"parent_document_type": "",
|
||||
"report_function": "Sum",
|
||||
"show_full_number": 0,
|
||||
"show_percentage_stats": 1,
|
||||
"stats_time_interval": "Weekly",
|
||||
"type": "Document Type"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"aggregate_function_based_on": "base_rounded_total",
|
||||
"creation": "2025-12-19 16:07:19.293268",
|
||||
"currency": "",
|
||||
"docstatus": 0,
|
||||
"doctype": "Number Card",
|
||||
"document_type": "Purchase Order",
|
||||
"dynamic_filters_json": "[[\"Purchase Order\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
|
||||
"filters_json": "[[\"Purchase Order\",\"transaction_date\",\"Timespan\",\"this quarter\"],[\"Purchase Order\",\"docstatus\",\"=\",\"1\"],[\"Purchase Order\",\"is_subcontracted\",\"=\",0]]",
|
||||
"function": "Sum",
|
||||
"idx": 0,
|
||||
"is_public": 1,
|
||||
"is_standard": 1,
|
||||
"label": "Total Purchase Amount",
|
||||
"modified": "2025-12-19 16:57:24.263495",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Total Purchase Amount",
|
||||
"owner": "Administrator",
|
||||
"parent_document_type": "",
|
||||
"report_function": "Sum",
|
||||
"show_full_number": 0,
|
||||
"show_percentage_stats": 0,
|
||||
"stats_time_interval": "Weekly",
|
||||
"type": "Document Type"
|
||||
}
|
||||
@@ -36,7 +36,7 @@ def get_chart_data(data, conditions, filters):
|
||||
|
||||
# fetch only periodic columns as labels
|
||||
columns = conditions.get("columns")[start:-2][2::2]
|
||||
labels = [column.split(":")[0] for column in columns]
|
||||
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
for row in data:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"app": "erpnext",
|
||||
"charts": [
|
||||
{
|
||||
"chart_name": "Purchase Order Trends",
|
||||
"label": "Purchase Order Trends"
|
||||
}
|
||||
],
|
||||
"content": "[{\"id\":\"I3JijHOxil\",\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Buying\",\"col\":12}},{\"id\":\"j3dJGo8Ok6\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Purchase Order Trends\",\"col\":12}},{\"id\":\"oN7lXSwQji\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"Ivw1PI_wEJ\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Your Shortcuts</b></span>\",\"col\":12}},{\"id\":\"RrWFEi4kCf\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"RFIakryyJP\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Material Request\",\"col\":3}},{\"id\":\"bM10abFmf6\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Purchase Order\",\"col\":3}},{\"id\":\"lR0Hw_37Pu\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Purchase Analytics\",\"col\":3}},{\"id\":\"_HN0Ljw1lX\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Purchase Order Analysis\",\"col\":3}},{\"id\":\"kuLuiMRdnX\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"tQFeiKptW2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Learn Procurement\",\"col\":3}},{\"id\":\"0NiuFE_EGS\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"Xe2GVLOq8J\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports & Masters</b></span>\",\"col\":12}},{\"id\":\"QwqyG6XuUt\",\"type\":\"card\",\"data\":{\"card_name\":\"Buying\",\"col\":4}},{\"id\":\"bTPjOxC_N_\",\"type\":\"card\",\"data\":{\"card_name\":\"Items & Pricing\",\"col\":4}},{\"id\":\"87ht0HIneb\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"EDOsBOmwgw\",\"type\":\"card\",\"data\":{\"card_name\":\"Supplier\",\"col\":4}},{\"id\":\"oWNNIiNb2i\",\"type\":\"card\",\"data\":{\"card_name\":\"Supplier Scorecard\",\"col\":4}},{\"id\":\"7F_13-ihHB\",\"type\":\"card\",\"data\":{\"card_name\":\"Key Reports\",\"col\":4}},{\"id\":\"pfwiLvionl\",\"type\":\"card\",\"data\":{\"card_name\":\"Other Reports\",\"col\":4}},{\"id\":\"8ySDy6s4qn\",\"type\":\"card\",\"data\":{\"card_name\":\"Regional\",\"col\":4}}]",
|
||||
"content": "[{\"id\":\"j3dJGo8Ok6\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Purchase Order Trends\",\"col\":12}},{\"id\":\"k75jSq2D6Z\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Purchase Orders Count\",\"col\":4}},{\"id\":\"UPXys0lQLj\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Total Purchase Amount\",\"col\":4}},{\"id\":\"yQGK3eb2hg\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Average Order Values\",\"col\":4}},{\"id\":\"oN7lXSwQji\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"Ivw1PI_wEJ\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Your Shortcuts</b></span>\",\"col\":12}},{\"id\":\"RrWFEi4kCf\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"RFIakryyJP\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Material Request\",\"col\":3}},{\"id\":\"bM10abFmf6\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Purchase Order\",\"col\":3}},{\"id\":\"lR0Hw_37Pu\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Purchase Analytics\",\"col\":3}},{\"id\":\"_HN0Ljw1lX\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Purchase Order Analysis\",\"col\":3}},{\"id\":\"kuLuiMRdnX\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":3}},{\"id\":\"tQFeiKptW2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Learn Procurement\",\"col\":3}},{\"id\":\"0NiuFE_EGS\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"Xe2GVLOq8J\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports & Masters</b></span>\",\"col\":12}},{\"id\":\"QwqyG6XuUt\",\"type\":\"card\",\"data\":{\"card_name\":\"Buying\",\"col\":4}},{\"id\":\"bTPjOxC_N_\",\"type\":\"card\",\"data\":{\"card_name\":\"Items & Pricing\",\"col\":4}},{\"id\":\"87ht0HIneb\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"EDOsBOmwgw\",\"type\":\"card\",\"data\":{\"card_name\":\"Supplier\",\"col\":4}},{\"id\":\"oWNNIiNb2i\",\"type\":\"card\",\"data\":{\"card_name\":\"Supplier Scorecard\",\"col\":4}},{\"id\":\"7F_13-ihHB\",\"type\":\"card\",\"data\":{\"card_name\":\"Key Reports\",\"col\":4}},{\"id\":\"pfwiLvionl\",\"type\":\"card\",\"data\":{\"card_name\":\"Other Reports\",\"col\":4}},{\"id\":\"8ySDy6s4qn\",\"type\":\"card\",\"data\":{\"card_name\":\"Regional\",\"col\":4}}]",
|
||||
"creation": "2020-01-28 11:50:26.195467",
|
||||
"custom_blocks": [],
|
||||
"docstatus": 0,
|
||||
@@ -511,11 +512,24 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2023-07-04 14:43:30.387683",
|
||||
"modified": "2025-12-19 16:12:02.461082",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Buying",
|
||||
"number_cards": [],
|
||||
"number_cards": [
|
||||
{
|
||||
"label": "Purchase Orders Count",
|
||||
"number_card_name": "Purchase Orders Count"
|
||||
},
|
||||
{
|
||||
"label": "Total Purchase Amount",
|
||||
"number_card_name": "Total Purchase Amount"
|
||||
},
|
||||
{
|
||||
"label": "Average Order Values",
|
||||
"number_card_name": "Average Order Values"
|
||||
}
|
||||
],
|
||||
"owner": "Administrator",
|
||||
"parent_page": "",
|
||||
"public": 1,
|
||||
@@ -558,11 +572,13 @@
|
||||
{
|
||||
"label": "Purchase Analytics",
|
||||
"link_to": "Purchase Analytics",
|
||||
"report_ref_doctype": "Purchase Order",
|
||||
"type": "Report"
|
||||
},
|
||||
{
|
||||
"label": "Purchase Order Analysis",
|
||||
"link_to": "Purchase Order Analysis",
|
||||
"report_ref_doctype": "Purchase Order",
|
||||
"type": "Report"
|
||||
},
|
||||
{
|
||||
@@ -571,5 +587,6 @@
|
||||
"type": "Dashboard"
|
||||
}
|
||||
],
|
||||
"title": "Buying"
|
||||
"title": "Buying",
|
||||
"type": "Workspace"
|
||||
}
|
||||
|
||||
@@ -315,13 +315,30 @@ class AccountsController(TransactionBase):
|
||||
|
||||
def validate_company_linked_addresses(self):
|
||||
address_fields = []
|
||||
if self.doctype in ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
|
||||
sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice")
|
||||
purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation")
|
||||
|
||||
if self.doctype in sales_doctypes:
|
||||
address_fields = ["dispatch_address_name", "company_address"]
|
||||
elif self.doctype in ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation"):
|
||||
elif self.doctype in purchase_doctypes:
|
||||
address_fields = ["billing_address", "shipping_address"]
|
||||
|
||||
if not address_fields:
|
||||
return
|
||||
|
||||
# Determine if drop ship applies
|
||||
is_drop_ship = self.doctype in {
|
||||
"Purchase Order",
|
||||
"Sales Order",
|
||||
"Sales Invoice",
|
||||
} and self.is_drop_ship(self.items)
|
||||
|
||||
for field in address_fields:
|
||||
address = self.get(field)
|
||||
|
||||
if (field in ["dispatch_address_name", "shipping_address"]) and is_drop_ship:
|
||||
continue
|
||||
|
||||
if address and not frappe.db.exists(
|
||||
"Dynamic Link",
|
||||
{
|
||||
@@ -332,11 +349,15 @@ class AccountsController(TransactionBase):
|
||||
},
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} does not belong to the {1}.").format(
|
||||
_("{0} does not belong to the Company {1}.").format(
|
||||
_(self.meta.get_label(field)), bold(self.company)
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_drop_ship(items):
|
||||
return any(item.delivered_by_supplier for item in items)
|
||||
|
||||
def set_default_letter_head(self):
|
||||
if hasattr(self, "letter_head") and not self.letter_head:
|
||||
self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head")
|
||||
@@ -2686,7 +2707,9 @@ class AccountsController(TransactionBase):
|
||||
|
||||
for d in self.get("payment_schedule"):
|
||||
d.validate_from_to_dates("discount_date", "due_date")
|
||||
if self.doctype == "Sales Order" and getdate(d.due_date) < getdate(self.transaction_date):
|
||||
if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate(
|
||||
self.transaction_date
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(
|
||||
d.idx
|
||||
@@ -3686,7 +3709,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
|
||||
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
|
||||
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
|
||||
|
||||
if child_doctype == "Purchase Order Item":
|
||||
if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]:
|
||||
# Initialized value will update in parent validation
|
||||
child_item.base_rate = 1
|
||||
child_item.base_amount = 1
|
||||
@@ -3704,7 +3727,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
|
||||
return child_item
|
||||
|
||||
|
||||
def validate_child_on_delete(row, parent):
|
||||
def validate_child_on_delete(row, parent, ordered_item=None):
|
||||
"""Check if partially transacted item (row) is being deleted."""
|
||||
if parent.doctype == "Sales Order":
|
||||
if flt(row.delivered_qty):
|
||||
@@ -3732,13 +3755,17 @@ def validate_child_on_delete(row, parent):
|
||||
row.idx, row.item_code
|
||||
)
|
||||
)
|
||||
|
||||
if flt(row.billed_amt):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Cannot delete item {1} which has already been billed.").format(
|
||||
row.idx, row.item_code
|
||||
if parent.doctype in ["Purchase Order", "Sales Order"]:
|
||||
if flt(row.billed_amt):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Cannot delete item {1} which has already been billed.").format(
|
||||
row.idx, row.item_code
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if parent.doctype == "Quotation":
|
||||
if ordered_item.get(row.name):
|
||||
frappe.throw(_("Cannot delete an item which has been ordered"))
|
||||
|
||||
|
||||
def update_bin_on_delete(row, doctype):
|
||||
@@ -3764,7 +3791,7 @@ def update_bin_on_delete(row, doctype):
|
||||
update_bin_qty(row.item_code, row.warehouse, qty_dict)
|
||||
|
||||
|
||||
def validate_and_delete_children(parent, data) -> bool:
|
||||
def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
|
||||
deleted_children = []
|
||||
updated_item_names = [d.get("docname") for d in data]
|
||||
for item in parent.items:
|
||||
@@ -3772,7 +3799,7 @@ def validate_and_delete_children(parent, data) -> bool:
|
||||
deleted_children.append(item)
|
||||
|
||||
for d in deleted_children:
|
||||
validate_child_on_delete(d, parent)
|
||||
validate_child_on_delete(d, parent, ordered_item)
|
||||
d.cancel()
|
||||
d.delete()
|
||||
|
||||
@@ -3781,16 +3808,19 @@ def validate_and_delete_children(parent, data) -> bool:
|
||||
|
||||
# need to update ordered qty in Material Request first
|
||||
# bin uses Material Request Items to recalculate & update
|
||||
parent.update_prevdoc_status()
|
||||
|
||||
for d in deleted_children:
|
||||
update_bin_on_delete(d, parent.doctype)
|
||||
if parent.doctype not in ["Quotation", "Supplier Quotation"]:
|
||||
parent.update_prevdoc_status()
|
||||
for d in deleted_children:
|
||||
update_bin_on_delete(d, parent.doctype)
|
||||
|
||||
return bool(deleted_children)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"):
|
||||
from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
|
||||
from erpnext.selling.doctype.quotation.quotation import get_ordered_items
|
||||
|
||||
def check_doc_permissions(doc, perm_type="create"):
|
||||
try:
|
||||
doc.check_permission(perm_type)
|
||||
@@ -3829,7 +3859,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
)
|
||||
|
||||
def get_new_child_item(item_row):
|
||||
child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item"
|
||||
child_doctype = parent_doctype + " Item"
|
||||
return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row)
|
||||
|
||||
def is_allowed_zero_qty():
|
||||
@@ -3854,6 +3884,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty):
|
||||
frappe.throw(_("Cannot set quantity less than received quantity"))
|
||||
|
||||
if parent_doctype in ["Quotation", "Supplier Quotation"]:
|
||||
if (parent_doctype == "Quotation" and not ordered_items) or (
|
||||
parent_doctype == "Supplier Quotation" and not purchased_items
|
||||
):
|
||||
return
|
||||
|
||||
qty_to_check = (
|
||||
ordered_items.get(child_item.name)
|
||||
if parent_doctype == "Quotation"
|
||||
else purchased_items.get(child_item.name)
|
||||
)
|
||||
if qty_to_check:
|
||||
if flt(new_data.get("qty")) < qty_to_check:
|
||||
frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
|
||||
|
||||
def should_update_supplied_items(doc) -> bool:
|
||||
"""Subcontracted PO can allow following changes *after submit*:
|
||||
|
||||
@@ -3896,7 +3941,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"]))
|
||||
|
||||
data = json.loads(trans_items)
|
||||
|
||||
any_qty_changed = False # updated to true if any item's qty changes
|
||||
items_added_or_removed = False # updated to true if any new item is added or removed
|
||||
any_conversion_factor_changed = False
|
||||
@@ -3904,7 +3948,16 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
parent = frappe.get_doc(parent_doctype, parent_doctype_name)
|
||||
|
||||
check_doc_permissions(parent, "write")
|
||||
_removed_items = validate_and_delete_children(parent, data)
|
||||
|
||||
if parent_doctype == "Quotation":
|
||||
ordered_items = get_ordered_items(parent.name)
|
||||
_removed_items = validate_and_delete_children(parent, data, ordered_items)
|
||||
elif parent_doctype == "Supplier Quotation":
|
||||
purchased_items = get_purchased_items(parent.name)
|
||||
_removed_items = validate_and_delete_children(parent, data, purchased_items)
|
||||
else:
|
||||
_removed_items = validate_and_delete_children(parent, data)
|
||||
|
||||
items_added_or_removed |= _removed_items
|
||||
|
||||
for d in data:
|
||||
@@ -3944,7 +3997,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
conversion_factor_unchanged = prev_con_fac == new_con_fac
|
||||
any_conversion_factor_changed |= not conversion_factor_unchanged
|
||||
date_unchanged = (
|
||||
prev_date == getdate(new_date) if prev_date and new_date else False
|
||||
(prev_date == getdate(new_date) if prev_date and new_date else False)
|
||||
if parent_doctype not in ["Quotation", "Supplier Quotation"]
|
||||
else None
|
||||
) # in case of delivery note etc
|
||||
if (
|
||||
rate_unchanged
|
||||
@@ -3957,6 +4012,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
continue
|
||||
|
||||
validate_quantity(child_item, d)
|
||||
if parent_doctype in ["Quotation", "Supplier Quotation"]:
|
||||
if not rate_unchanged:
|
||||
frappe.throw(_("Rates cannot be modified for quoted items"))
|
||||
|
||||
if flt(child_item.get("qty")) != flt(d.get("qty")):
|
||||
any_qty_changed = True
|
||||
|
||||
@@ -3980,18 +4039,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
rate_unchanged = prev_rate == new_rate
|
||||
if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty():
|
||||
frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
|
||||
|
||||
# Amount cannot be lesser than billed amount, except for negative amounts
|
||||
row_rate = flt(d.get("rate"), rate_precision)
|
||||
amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
|
||||
row_rate * flt(d.get("qty"), qty_precision), rate_precision
|
||||
)
|
||||
if amount_below_billed_amt and row_rate > 0.0:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
|
||||
).format(child_item.idx, child_item.item_code)
|
||||
|
||||
if parent_doctype in ["Purchase Order", "Sales Order"]:
|
||||
amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
|
||||
row_rate * flt(d.get("qty"), qty_precision), rate_precision
|
||||
)
|
||||
if amount_below_billed_amt and row_rate > 0.0:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
|
||||
).format(child_item.idx, child_item.item_code)
|
||||
)
|
||||
else:
|
||||
child_item.rate = row_rate
|
||||
else:
|
||||
child_item.rate = row_rate
|
||||
|
||||
@@ -4019,26 +4081,27 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
if d.get("bom_no") and parent_doctype == "Sales Order":
|
||||
child_item.bom_no = d.get("bom_no")
|
||||
|
||||
if flt(child_item.price_list_rate):
|
||||
if flt(child_item.rate) > flt(child_item.price_list_rate):
|
||||
# if rate is greater than price_list_rate, set margin
|
||||
# or set discount
|
||||
child_item.discount_percentage = 0
|
||||
child_item.margin_type = "Amount"
|
||||
child_item.margin_rate_or_amount = flt(
|
||||
child_item.rate - child_item.price_list_rate,
|
||||
child_item.precision("margin_rate_or_amount"),
|
||||
)
|
||||
child_item.rate_with_margin = child_item.rate
|
||||
else:
|
||||
child_item.discount_percentage = flt(
|
||||
(1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0,
|
||||
child_item.precision("discount_percentage"),
|
||||
)
|
||||
child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate)
|
||||
child_item.margin_type = ""
|
||||
child_item.margin_rate_or_amount = 0
|
||||
child_item.rate_with_margin = 0
|
||||
if parent_doctype in ["Sales Order", "Purchase Order"]:
|
||||
if flt(child_item.price_list_rate):
|
||||
if flt(child_item.rate) > flt(child_item.price_list_rate):
|
||||
# if rate is greater than price_list_rate, set margin
|
||||
# or set discount
|
||||
child_item.discount_percentage = 0
|
||||
child_item.margin_type = "Amount"
|
||||
child_item.margin_rate_or_amount = flt(
|
||||
child_item.rate - child_item.price_list_rate,
|
||||
child_item.precision("margin_rate_or_amount"),
|
||||
)
|
||||
child_item.rate_with_margin = child_item.rate
|
||||
else:
|
||||
child_item.discount_percentage = flt(
|
||||
(1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0,
|
||||
child_item.precision("discount_percentage"),
|
||||
)
|
||||
child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate)
|
||||
child_item.margin_type = ""
|
||||
child_item.margin_rate_or_amount = 0
|
||||
child_item.rate_with_margin = 0
|
||||
|
||||
child_item.flags.ignore_validate_update_after_submit = True
|
||||
if new_child_flag:
|
||||
@@ -4060,13 +4123,14 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
parent.doctype, parent.company, parent.base_grand_total
|
||||
)
|
||||
|
||||
parent.set_payment_schedule()
|
||||
if parent_doctype != "Supplier Quotation":
|
||||
parent.set_payment_schedule()
|
||||
if parent_doctype == "Purchase Order":
|
||||
parent.validate_minimum_order_qty()
|
||||
parent.validate_budget()
|
||||
if parent.is_against_so():
|
||||
parent.update_status_updater()
|
||||
else:
|
||||
elif parent_doctype == "Sales Order":
|
||||
parent.check_credit_limit()
|
||||
|
||||
# reset index of child table
|
||||
@@ -4099,7 +4163,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
"Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}."
|
||||
).format(frappe.bold(parent.name))
|
||||
)
|
||||
else: # Sales Order
|
||||
elif parent_doctype == "Sales Order": # Sales Order
|
||||
if parent.is_subcontracted and not parent.can_update_items():
|
||||
frappe.throw(
|
||||
_(
|
||||
@@ -4117,9 +4181,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
parent.reload()
|
||||
validate_workflow_conditions(parent)
|
||||
|
||||
parent.update_blanket_order()
|
||||
parent.update_billing_percentage()
|
||||
parent.set_status()
|
||||
if parent_doctype in ["Purchase Order", "Sales Order"]:
|
||||
parent.update_blanket_order()
|
||||
parent.update_billing_percentage()
|
||||
parent.set_status()
|
||||
|
||||
parent.validate_uom_is_integer("uom", "qty")
|
||||
parent.validate_uom_is_integer("stock_uom", "stock_qty")
|
||||
|
||||
@@ -81,6 +81,19 @@ class BuyingController(SubcontractingController):
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.get("company")
|
||||
and (
|
||||
default_buying_terms := frappe.get_value(
|
||||
"Company", self.get("company"), "default_buying_terms"
|
||||
)
|
||||
)
|
||||
and not self.get("tc_name")
|
||||
and not self.get("terms")
|
||||
):
|
||||
self.tc_name = default_buying_terms
|
||||
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
|
||||
|
||||
def validate_posting_date_with_po(self):
|
||||
po_list = {x.purchase_order for x in self.items if x.purchase_order}
|
||||
|
||||
|
||||
@@ -43,6 +43,19 @@ class SellingController(StockController):
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
self.get("company")
|
||||
and (
|
||||
default_selling_terms := frappe.get_value(
|
||||
"Company", self.get("company"), "default_selling_terms"
|
||||
)
|
||||
)
|
||||
and not self.get("tc_name")
|
||||
and not self.get("terms")
|
||||
):
|
||||
self.tc_name = default_selling_terms
|
||||
self.terms = frappe.get_value("Terms and Conditions", self.get("tc_name"), "terms")
|
||||
|
||||
def validate(self):
|
||||
super().validate()
|
||||
self.validate_items()
|
||||
|
||||
@@ -94,6 +94,7 @@ status_map = {
|
||||
["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"],
|
||||
["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"],
|
||||
["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"],
|
||||
["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"],
|
||||
["Cancelled", "eval:self.docstatus==2"],
|
||||
["Closed", "eval:self.status=='Closed' and self.docstatus != 2"],
|
||||
],
|
||||
@@ -129,7 +130,7 @@ status_map = {
|
||||
],
|
||||
[
|
||||
"Received",
|
||||
"eval:self.status != 'Stopped' and self.per_received == 100 and self.docstatus == 1 and self.material_request_type == 'Purchase'",
|
||||
"eval:self.status != 'Stopped' and self.docstatus == 1 and ((self.per_received == 100 and self.material_request_type == 'Purchase') or (self.per_ordered == 100 and self.material_request_type == 'Customer Provided'))",
|
||||
],
|
||||
[
|
||||
"Partially Received",
|
||||
@@ -137,11 +138,11 @@ status_map = {
|
||||
],
|
||||
[
|
||||
"Partially Received",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type == 'Material Transfer'",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type in ['Material Transfer', 'Customer Provided']",
|
||||
],
|
||||
[
|
||||
"Partially Ordered",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'",
|
||||
"eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']",
|
||||
],
|
||||
],
|
||||
"POS Opening Entry": [
|
||||
@@ -392,12 +393,16 @@ class StatusUpdater(Document):
|
||||
self.item_allowance,
|
||||
self.global_qty_allowance,
|
||||
self.global_amount_allowance,
|
||||
) = get_allowance_for(
|
||||
item["item_code"],
|
||||
self.item_allowance,
|
||||
self.global_qty_allowance,
|
||||
self.global_amount_allowance,
|
||||
qty_or_amount,
|
||||
) = (
|
||||
get_allowance_for(
|
||||
item["item_code"],
|
||||
self.item_allowance,
|
||||
self.global_qty_allowance,
|
||||
self.global_amount_allowance,
|
||||
qty_or_amount,
|
||||
)
|
||||
if args["source_dt"] != "Pick List Item"
|
||||
else (0, {}, None, None)
|
||||
)
|
||||
|
||||
role_allowed_to_over_deliver_receive = frappe.get_single_value(
|
||||
@@ -435,14 +440,17 @@ class StatusUpdater(Document):
|
||||
):
|
||||
return
|
||||
|
||||
if qty_or_amount == "qty":
|
||||
action_msg = _(
|
||||
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
|
||||
)
|
||||
if args["source_dt"] != "Pick List Item":
|
||||
if qty_or_amount == "qty":
|
||||
action_msg = _(
|
||||
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
|
||||
)
|
||||
else:
|
||||
action_msg = _(
|
||||
'To allow over billing, update "Over Billing Allowance" in Accounts Settings or the Item.'
|
||||
)
|
||||
else:
|
||||
action_msg = _(
|
||||
'To allow over billing, update "Over Billing Allowance" in Accounts Settings or the Item.'
|
||||
)
|
||||
action_msg = None
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
@@ -454,8 +462,7 @@ class StatusUpdater(Document):
|
||||
frappe.bold(_(self.doctype)),
|
||||
frappe.bold(item.get("item_code")),
|
||||
)
|
||||
+ "<br><br>"
|
||||
+ action_msg,
|
||||
+ ("<br><br>" + action_msg if action_msg else ""),
|
||||
OverAllowanceError,
|
||||
title=_("Limit Crossed"),
|
||||
)
|
||||
|
||||
@@ -221,7 +221,7 @@ class SubcontractingController(StockController):
|
||||
and self._doc_before_save
|
||||
):
|
||||
for row in self._doc_before_save.get("items"):
|
||||
item_dict[row.name] = (row.item_code, row.qty)
|
||||
item_dict[row.name] = (row.item_code, row.qty + (row.get("rejected_qty") or 0))
|
||||
|
||||
return item_dict
|
||||
|
||||
@@ -245,7 +245,10 @@ class SubcontractingController(StockController):
|
||||
|
||||
for row in self.items:
|
||||
self.__reference_name.append(row.name)
|
||||
if (row.name not in item_dict) or (row.item_code, row.qty) != item_dict[row.name]:
|
||||
if (row.name not in item_dict) or (
|
||||
row.item_code,
|
||||
row.qty + (row.get("rejected_qty") or 0),
|
||||
) != item_dict[row.name]:
|
||||
self.__changed_name.append(row.name)
|
||||
|
||||
if item_dict.get(row.name):
|
||||
@@ -934,7 +937,11 @@ class SubcontractingController(StockController):
|
||||
for bom_item in self._get_materials_from_bom(
|
||||
row.item_code, row.bom, row.get("include_exploded_items")
|
||||
):
|
||||
qty = flt(bom_item.qty_consumed_per_unit) * flt(row.qty) * row.conversion_factor
|
||||
qty = (
|
||||
flt(bom_item.qty_consumed_per_unit)
|
||||
* flt(row.qty + (row.get("rejected_qty") or 0))
|
||||
* row.conversion_factor
|
||||
)
|
||||
bom_item.main_item_code = row.item_code
|
||||
self.__update_reserve_warehouse(bom_item, row)
|
||||
self.__set_alternative_item(bom_item)
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.model.document import Document, bulk_insert
|
||||
from frappe.query_builder import functions
|
||||
from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
|
||||
|
||||
import erpnext
|
||||
@@ -397,6 +398,9 @@ class calculate_taxes_and_totals:
|
||||
self._calculate()
|
||||
|
||||
def calculate_taxes(self):
|
||||
# reset value from earlier calculations
|
||||
self.grand_total_diff = 0
|
||||
|
||||
doc = self.doc
|
||||
if not doc.get("taxes"):
|
||||
return
|
||||
@@ -683,7 +687,7 @@ class calculate_taxes_and_totals:
|
||||
self.grand_total_diff = 0
|
||||
|
||||
def calculate_totals(self):
|
||||
grand_total_diff = getattr(self, "grand_total_diff", 0)
|
||||
grand_total_diff = self.grand_total_diff
|
||||
|
||||
if self.doc.get("taxes"):
|
||||
self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + grand_total_diff
|
||||
@@ -776,6 +780,22 @@ class calculate_taxes_and_totals:
|
||||
discount_amount = self.doc.discount_amount or 0
|
||||
grand_total = self.doc.grand_total
|
||||
|
||||
if self.doc.get("is_return") and self.doc.get("return_against"):
|
||||
doctype = frappe.qb.DocType(self.doc.doctype)
|
||||
|
||||
result = (
|
||||
frappe.qb.from_(doctype)
|
||||
.select(functions.Sum(doctype.discount_amount).as_("total_return_discount"))
|
||||
.where(
|
||||
(doctype.return_against == self.doc.return_against)
|
||||
& (doctype.is_return == 1)
|
||||
& (doctype.docstatus == 1)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
total_return_discount = abs(result[0].get("total_return_discount") or 0)
|
||||
discount_amount += total_return_discount
|
||||
|
||||
# validate that discount amount cannot exceed the total before discount
|
||||
if (
|
||||
(grand_total >= 0 and discount_amount > grand_total)
|
||||
@@ -924,12 +944,11 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
)
|
||||
|
||||
if self.doc.docstatus.is_draft():
|
||||
if self.doc.get("write_off_outstanding_amount_automatically"):
|
||||
self.doc.write_off_amount = 0
|
||||
if self.doc.get("write_off_outstanding_amount_automatically"):
|
||||
self.doc.write_off_amount = 0
|
||||
|
||||
self.calculate_outstanding_amount()
|
||||
self.calculate_write_off_amount()
|
||||
self.calculate_outstanding_amount()
|
||||
self.calculate_write_off_amount()
|
||||
|
||||
def is_internal_invoice(self):
|
||||
"""
|
||||
|
||||
@@ -2438,6 +2438,7 @@ class TestAccountsController(IntegrationTestCase):
|
||||
|
||||
def test_company_linked_address(self):
|
||||
from erpnext.crm.doctype.prospect.test_prospect import make_address
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
company_address = make_address(
|
||||
address_title="Company", address_type="Shipping", address_line1="100", city="Mumbai"
|
||||
@@ -2466,3 +2467,16 @@ class TestAccountsController(IntegrationTestCase):
|
||||
po.billing_address = company_address.name
|
||||
po.reload()
|
||||
po.save()
|
||||
|
||||
si = make_sales_order(do_not_save=1, do_not_submit=1)
|
||||
si.dispatch_address_name = supplier_billing.name
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
si.items[0].delivered_by_supplier = 1
|
||||
si.items[0].supplier = "_Test Supplier"
|
||||
si.save()
|
||||
|
||||
po = create_purchase_order(do_not_save=True)
|
||||
po.shipping_address = customer_shipping.name
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
po.items[0].delivered_by_supplier = 1
|
||||
po.save()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:market_segment",
|
||||
"creation": "2018-10-01 09:59:14.479509",
|
||||
"doctype": "DocType",
|
||||
@@ -17,10 +18,11 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2024-08-16 19:24:55.811760",
|
||||
"modified": "2025-12-17 12:09:34.687368",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Market Segment",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -37,9 +39,10 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1,
|
||||
"translated_doctype": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,14 @@ website_route_rules = [
|
||||
"parents": [{"label": "Purchase Order", "route": "purchase-orders"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"from_route": "/purchase-orders/<path:name>",
|
||||
"to_route": "order",
|
||||
"defaults": {
|
||||
"doctype": "Purchase Order",
|
||||
"parents": [{"label": "Purchase Order", "route": "purchase-orders"}],
|
||||
},
|
||||
},
|
||||
{"from_route": "/purchase-invoices", "to_route": "Purchase Invoice"},
|
||||
{
|
||||
"from_route": "/purchase-invoices/<path:name>",
|
||||
@@ -411,7 +419,9 @@ scheduler_events = {
|
||||
"0/15 * * * *": [
|
||||
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
|
||||
],
|
||||
"0/30 * * * *": [],
|
||||
"0/30 * * * *": [
|
||||
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting",
|
||||
],
|
||||
# Hourly but offset by 30 minutes
|
||||
"30 * * * *": [
|
||||
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
|
||||
|
||||
2913
erpnext/locale/ar.po
2913
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3493
erpnext/locale/bs.po
3493
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/cs.po
2909
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/da.po
2909
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
2917
erpnext/locale/de.po
2917
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
2917
erpnext/locale/eo.po
2917
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
2913
erpnext/locale/es.po
2913
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
2947
erpnext/locale/fa.po
2947
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
2913
erpnext/locale/fr.po
2913
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
2917
erpnext/locale/hr.po
2917
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
2929
erpnext/locale/hu.po
2929
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
2911
erpnext/locale/id.po
2911
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/it.po
2909
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/my.po
2909
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
2911
erpnext/locale/nb.po
2911
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/nl.po
2909
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
2913
erpnext/locale/pl.po
2913
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/pt.po
2909
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
20852
erpnext/locale/ru.po
20852
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
3321
erpnext/locale/sl.po
3321
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
2917
erpnext/locale/sr.po
2917
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2929
erpnext/locale/sv.po
2929
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/ta.po
2909
erpnext/locale/ta.po
File diff suppressed because it is too large
Load Diff
2913
erpnext/locale/th.po
2913
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
2917
erpnext/locale/tr.po
2917
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
2909
erpnext/locale/vi.po
2909
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
2917
erpnext/locale/zh.po
2917
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
0
erpnext/maintenance/report/__init__.py
Normal file
0
erpnext/maintenance/report/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user