mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-25 20:08:34 +00:00
Compare commits
552 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621558a30c | ||
|
|
547fbec55f | ||
|
|
d9bd42965a | ||
|
|
419df361a7 | ||
|
|
6ea4f1a03d | ||
|
|
65ed4e5cf6 | ||
|
|
b268de4609 | ||
|
|
44b726c2e3 | ||
|
|
0c395725b7 | ||
|
|
7146c0385c | ||
|
|
e12564daa6 | ||
|
|
a09b73e65d | ||
|
|
654a55260d | ||
|
|
90dc22a57d | ||
|
|
e826e03f9a | ||
|
|
de4e62e308 | ||
|
|
468ec805f1 | ||
|
|
cd8c6eac7c | ||
|
|
90d6bb34dc | ||
|
|
1545904693 | ||
|
|
dadd4b1f95 | ||
|
|
0c89cd5524 | ||
|
|
934b5494f0 | ||
|
|
72a9b58b14 | ||
|
|
5cfd8d1930 | ||
|
|
74bf61e0c1 | ||
|
|
c4b135e1a2 | ||
|
|
853facad96 | ||
|
|
636e1ac1f1 | ||
|
|
df996b8fd3 | ||
|
|
5e6192249e | ||
|
|
398e8d00ec | ||
|
|
6be30bbd71 | ||
|
|
14de520ebb | ||
|
|
770d0e7f7f | ||
|
|
c351d6b1c0 | ||
|
|
a4b099e481 | ||
|
|
624ec19305 | ||
|
|
e1c3125efa | ||
|
|
d4195d31bf | ||
|
|
f349be0a00 | ||
|
|
d89ac99e76 | ||
|
|
9fce694936 | ||
|
|
b5d8477354 | ||
|
|
0e3d276348 | ||
|
|
3489b65f1a | ||
|
|
c8a52ec43c | ||
|
|
f9fd0ffbae | ||
|
|
69dc9e81d5 | ||
|
|
0a87fa5348 | ||
|
|
81e7e96cb6 | ||
|
|
f7770c3225 | ||
|
|
f61305aa45 | ||
|
|
113a6e079a | ||
|
|
c35426b9f9 | ||
|
|
83352b5a34 | ||
|
|
e54bb0da69 | ||
|
|
8d06ee3966 | ||
|
|
6b4101d202 | ||
|
|
386567a6ea | ||
|
|
d3440cf545 | ||
|
|
11544818f1 | ||
|
|
1e16e751ee | ||
|
|
cff3407a4b | ||
|
|
502a262637 | ||
|
|
8f112c5967 | ||
|
|
dad7657853 | ||
|
|
ff84edcfad | ||
|
|
184fa889c3 | ||
|
|
eff9595e34 | ||
|
|
8847e1c2bd | ||
|
|
1d5f406930 | ||
|
|
650f874fbd | ||
|
|
091272409e | ||
|
|
c2995f6800 | ||
|
|
39cd371fb6 | ||
|
|
8f6095d05f | ||
|
|
6b61eabf61 | ||
|
|
25112468bc | ||
|
|
d27fe6f57a | ||
|
|
e60064f6f1 | ||
|
|
e621a51225 | ||
|
|
054468a5ef | ||
|
|
546ab05eb5 | ||
|
|
8b2778b29f | ||
|
|
4418fb48a9 | ||
|
|
cfafd39543 | ||
|
|
794d005923 | ||
|
|
da19761fbd | ||
|
|
5fcda5f3ed | ||
|
|
763cf6ae10 | ||
|
|
b301be1a74 | ||
|
|
e568ab2255 | ||
|
|
61295e7d47 | ||
|
|
6af6fe8204 | ||
|
|
d0d776486e | ||
|
|
75b4a0a89c | ||
|
|
a2c86dbe01 | ||
|
|
2bf75a2c24 | ||
|
|
2b38bc191e | ||
|
|
62252170dd | ||
|
|
c55512c9d3 | ||
|
|
be2069883e | ||
|
|
e9573b0b93 | ||
|
|
1d64373c26 | ||
|
|
6df80901b9 | ||
|
|
3ef4fa51dc | ||
|
|
cce32507d9 | ||
|
|
d196956307 | ||
|
|
4db62cab3b | ||
|
|
fbf4305028 | ||
|
|
eef26fea9a | ||
|
|
043d208580 | ||
|
|
c508ef5b82 | ||
|
|
1da781f2ae | ||
|
|
e2b53884fe | ||
|
|
de46ac8b62 | ||
|
|
6219d7d9a5 | ||
|
|
eb0249310c | ||
|
|
dfb1722dc4 | ||
|
|
c13f3ba695 | ||
|
|
add635b9eb | ||
|
|
28a670434d | ||
|
|
90e8090dcc | ||
|
|
d983280de8 | ||
|
|
b3df300ea5 | ||
|
|
0a363f879d | ||
|
|
3008c7ad82 | ||
|
|
d82ab066bd | ||
|
|
df46841f82 | ||
|
|
14d197d9eb | ||
|
|
9819ed112b | ||
|
|
85f635ac4a | ||
|
|
d5982cab03 | ||
|
|
f618bf212f | ||
|
|
ddca3b5800 | ||
|
|
a8dbf981d8 | ||
|
|
b807f9318f | ||
|
|
a66129af29 | ||
|
|
ed05b4cc5c | ||
|
|
00ac931722 | ||
|
|
3365bc3ba3 | ||
|
|
11d23e1a4a | ||
|
|
07de3f4391 | ||
|
|
129457b2ce | ||
|
|
426516a1ee | ||
|
|
dae6adfe13 | ||
|
|
f5cae2d60b | ||
|
|
4e94e3726c | ||
|
|
d7bf1a179a | ||
|
|
6632f3d446 | ||
|
|
b909ec9388 | ||
|
|
ea0b76831f | ||
|
|
57c759dfcd | ||
|
|
c7f79d16e9 | ||
|
|
940cfb58a7 | ||
|
|
159d1d61b5 | ||
|
|
d8232c4503 | ||
|
|
ae72b99846 | ||
|
|
f9be364bd1 | ||
|
|
7ac55379ec | ||
|
|
aa43715de6 | ||
|
|
01af6c8762 | ||
|
|
ee9debe581 | ||
|
|
d83365734e | ||
|
|
1fb554c312 | ||
|
|
4c53af0494 | ||
|
|
e9c14e88df | ||
|
|
e6dbd06435 | ||
|
|
9d5a493609 | ||
|
|
530c0b0bd6 | ||
|
|
5193dbba9b | ||
|
|
42658f7b1c | ||
|
|
1bbeecff12 | ||
|
|
7db6ae8bda | ||
|
|
2bdd14c831 | ||
|
|
c805c7fac4 | ||
|
|
7238636766 | ||
|
|
8f1509dca1 | ||
|
|
a7f59fece3 | ||
|
|
1179514118 | ||
|
|
69259c9933 | ||
|
|
7aee6bdaf8 | ||
|
|
f11fb0e45f | ||
|
|
d17debabf7 | ||
|
|
0b565026a4 | ||
|
|
5fcf5d58f0 | ||
|
|
fac865a1b4 | ||
|
|
d3f2da0d59 | ||
|
|
f8fb58feaf | ||
|
|
9bba78f7a2 | ||
|
|
a0566c9e98 | ||
|
|
64a7e3f683 | ||
|
|
dbd2964139 | ||
|
|
dc10ef4287 | ||
|
|
3cc41cf643 | ||
|
|
582db48ca5 | ||
|
|
0452820ab0 | ||
|
|
1d58e9b91a | ||
|
|
7bbcafed8d | ||
|
|
d6511b0045 | ||
|
|
0d790a6cd5 | ||
|
|
728a8b0b7d | ||
|
|
d8cb65e440 | ||
|
|
eebd88529f | ||
|
|
1740fce6c8 | ||
|
|
a01dc0e205 | ||
|
|
1ec2cc3820 | ||
|
|
9ef7d45486 | ||
|
|
02203ca534 | ||
|
|
46f3ab1c39 | ||
|
|
4b6097914a | ||
|
|
decc27a446 | ||
|
|
2d49cc9ab2 | ||
|
|
5955b699c3 | ||
|
|
c8d8ec91c4 | ||
|
|
a857923853 | ||
|
|
4f6499836e | ||
|
|
6f1cfdb1de | ||
|
|
4d31012df2 | ||
|
|
944dacc12f | ||
|
|
9ef0e8beb7 | ||
|
|
cd930c05b8 | ||
|
|
c42aa4f89b | ||
|
|
3f4ffcc955 | ||
|
|
6f3904a20a | ||
|
|
e6acdf36e2 | ||
|
|
9ee40351c5 | ||
|
|
431e68741b | ||
|
|
f1c98df7bb | ||
|
|
1c40a61d23 | ||
|
|
fdf80a6d02 | ||
|
|
b9c123bd89 | ||
|
|
24f6f1e434 | ||
|
|
f263a7f65c | ||
|
|
d9888d5195 | ||
|
|
52b3740eb1 | ||
|
|
1cb22f9d05 | ||
|
|
507a561922 | ||
|
|
88e305f5a2 | ||
|
|
9d2e0f67d5 | ||
|
|
fd718833b1 | ||
|
|
c7c938c259 | ||
|
|
35ae839ab7 | ||
|
|
4753594a26 | ||
|
|
4e4d2cefda | ||
|
|
a04f560048 | ||
|
|
c9d8c5b419 | ||
|
|
93c1a3f8f3 | ||
|
|
926b4c7065 | ||
|
|
f29ad04eab | ||
|
|
8fa73b370a | ||
|
|
2645bf648d | ||
|
|
c01f20da00 | ||
|
|
d3f434b803 | ||
|
|
0687b035b5 | ||
|
|
04a98b2b64 | ||
|
|
eae1886043 | ||
|
|
5f295c5310 | ||
|
|
fe80d1d0e7 | ||
|
|
5e7b674ee4 | ||
|
|
4166c7ff47 | ||
|
|
0f2fb54756 | ||
|
|
9409155594 | ||
|
|
8a01a709a7 | ||
|
|
cc1f38010d | ||
|
|
f0aefa4274 | ||
|
|
cbcfe6ec36 | ||
|
|
6ff002dbe3 | ||
|
|
2f10b9c510 | ||
|
|
83b1e037cb | ||
|
|
5007abf7ae | ||
|
|
d31dd1a023 | ||
|
|
6df222a1ca | ||
|
|
265da1056d | ||
|
|
670beae048 | ||
|
|
6b2a077bec | ||
|
|
43831e9785 | ||
|
|
7187992170 | ||
|
|
f3d0a91fb3 | ||
|
|
f318a3658d | ||
|
|
8f52f14505 | ||
|
|
425dcee5bf | ||
|
|
06aded08ae | ||
|
|
c095938e69 | ||
|
|
aefde87a0c | ||
|
|
c89fe9f1ca | ||
|
|
658a7c536d | ||
|
|
94c430cc6e | ||
|
|
0e73c12add | ||
|
|
aba3d7821c | ||
|
|
05ad50f98b | ||
|
|
5200739d7b | ||
|
|
73643de612 | ||
|
|
fab49e41a6 | ||
|
|
bb00bb83f8 | ||
|
|
72d77a5e99 | ||
|
|
16112630ea | ||
|
|
dd19b95113 | ||
|
|
68eeba41c1 | ||
|
|
c77c426652 | ||
|
|
dc5faa8b71 | ||
|
|
26a36d807e | ||
|
|
432b33ac5f | ||
|
|
ca835c831b | ||
|
|
10b0da8bc8 | ||
|
|
e7fcacbe69 | ||
|
|
57c356a1cd | ||
|
|
6e7de0ac47 | ||
|
|
2277b1aff5 | ||
|
|
58c793f14e | ||
|
|
08cd08adcd | ||
|
|
35478bbf91 | ||
|
|
40481508f1 | ||
|
|
9ade0725e8 | ||
|
|
b20405dbf2 | ||
|
|
ac2402dd2a | ||
|
|
c827fc3259 | ||
|
|
f13db03c9b | ||
|
|
54ed428225 | ||
|
|
c046dad2c3 | ||
|
|
195f90232d | ||
|
|
8ef09c0dc0 | ||
|
|
7f91f95f95 | ||
|
|
696a0892fa | ||
|
|
59aef4fc8c | ||
|
|
99cd7cf63e | ||
|
|
44082cae72 | ||
|
|
d7c50cfa7c | ||
|
|
1e52738150 | ||
|
|
cbd0a76645 | ||
|
|
5b1795b0a5 | ||
|
|
552c5b5911 | ||
|
|
89d6a8f02e | ||
|
|
069262dd4d | ||
|
|
048865811c | ||
|
|
2d42904bfb | ||
|
|
66b2b89bcd | ||
|
|
d804d43ed0 | ||
|
|
53bb2cf7c0 | ||
|
|
2aff16928c | ||
|
|
630dcf072f | ||
|
|
b73eb47a43 | ||
|
|
981c9c76c1 | ||
|
|
6a6398a392 | ||
|
|
0b6b73b500 | ||
|
|
325fc619dc | ||
|
|
2c9c6c3798 | ||
|
|
3a9888aad9 | ||
|
|
695ca39d84 | ||
|
|
9032d4c3d6 | ||
|
|
c01e40da3c | ||
|
|
552cb5c528 | ||
|
|
e6ccb00d4c | ||
|
|
5b481d9235 | ||
|
|
6b27b659e3 | ||
|
|
16c8b74d52 | ||
|
|
8dcb2f39c2 | ||
|
|
5d5dff9103 | ||
|
|
b529a6d00c | ||
|
|
41659a875b | ||
|
|
a61890ec2b | ||
|
|
fa04e368d3 | ||
|
|
98eeff8775 | ||
|
|
0452b22aa6 | ||
|
|
64acf179db | ||
|
|
0d5e45bb7c | ||
|
|
edcf24afa9 | ||
|
|
e403dfe73a | ||
|
|
dffd5d9cdd | ||
|
|
fc86784eb1 | ||
|
|
e1dc80b6d8 | ||
|
|
9e8bb9b235 | ||
|
|
ae90ee3f17 | ||
|
|
dab8ac7b1d | ||
|
|
ce769d3a2f | ||
|
|
d09857294c | ||
|
|
3a74968ced | ||
|
|
26725f4e53 | ||
|
|
030ce6d6a0 | ||
|
|
b46d93c709 | ||
|
|
5d01cad1d5 | ||
|
|
97b253740b | ||
|
|
94c3d66f2f | ||
|
|
a2b6e4a1c5 | ||
|
|
ebbecdba23 | ||
|
|
2263f9a477 | ||
|
|
b340d7d4f4 | ||
|
|
99b69c121e | ||
|
|
860486bb34 | ||
|
|
1693e3ef3f | ||
|
|
279cf6fe00 | ||
|
|
cdc04292f2 | ||
|
|
6788b58d1c | ||
|
|
5bfdc010f3 | ||
|
|
0e7efd75cd | ||
|
|
2d198e698a | ||
|
|
857ab70f4e | ||
|
|
ca21f16db2 | ||
|
|
5324000e2e | ||
|
|
9d2055c620 | ||
|
|
113da4f512 | ||
|
|
8d32ba9a2e | ||
|
|
23c82d410b | ||
|
|
580e825ec2 | ||
|
|
1e0532f387 | ||
|
|
8f569d9711 | ||
|
|
df820aece6 | ||
|
|
6530cfe84b | ||
|
|
c8565c47a2 | ||
|
|
f3c70a66b5 | ||
|
|
115fd48bbf | ||
|
|
918f8ca79b | ||
|
|
46ca347578 | ||
|
|
8226502956 | ||
|
|
71e537b030 | ||
|
|
8fd3e8e22e | ||
|
|
4ee4a57f72 | ||
|
|
a8ed2815a4 | ||
|
|
a2b676b340 | ||
|
|
691db5b877 | ||
|
|
7bec3d19ac | ||
|
|
3f85aa3aea | ||
|
|
9ccf4900fe | ||
|
|
bf1c606610 | ||
|
|
6c53d31f2d | ||
|
|
4de1af498b | ||
|
|
c65409c348 | ||
|
|
422aec12cb | ||
|
|
52e26b6da8 | ||
|
|
ebb62966d3 | ||
|
|
b05e2910d8 | ||
|
|
3785ffe5c9 | ||
|
|
a4ab198042 | ||
|
|
67c5249b38 | ||
|
|
af067d1c00 | ||
|
|
b4053ee0d8 | ||
|
|
9f846e2636 | ||
|
|
8d734df63b | ||
|
|
0998123e52 | ||
|
|
f2c556a6cc | ||
|
|
b41612bea8 | ||
|
|
da88196a89 | ||
|
|
bde209b077 | ||
|
|
1a278e7ca0 | ||
|
|
1637cb4168 | ||
|
|
d9e9f35230 | ||
|
|
cbc73148d3 | ||
|
|
60a18247e1 | ||
|
|
7cc0436083 | ||
|
|
f8eb48472e | ||
|
|
8074d396d0 | ||
|
|
cfbd71693b | ||
|
|
3a2d7d18a3 | ||
|
|
b55cefc54f | ||
|
|
05778bb81a | ||
|
|
a70296e9b5 | ||
|
|
bd6210a212 | ||
|
|
944c9ad0b3 | ||
|
|
bd00a484ea | ||
|
|
1427b4ac3f | ||
|
|
fd6e42e15e | ||
|
|
8b071c0d22 | ||
|
|
176ce0d4d6 | ||
|
|
dd888fc30a | ||
|
|
789adaeabe | ||
|
|
2342f8d710 | ||
|
|
8b22d9d95e | ||
|
|
626c799b60 | ||
|
|
b2f6d07c25 | ||
|
|
70d57260d6 | ||
|
|
9062b90237 | ||
|
|
40467bc26c | ||
|
|
abe599a49d | ||
|
|
9975f5fe69 | ||
|
|
dab17c194c | ||
|
|
8a3148eee6 | ||
|
|
1f79242366 | ||
|
|
3f673a6848 | ||
|
|
293f114c9d | ||
|
|
252cc89ec7 | ||
|
|
3eaccfe201 | ||
|
|
653bb1072f | ||
|
|
0a64e43e92 | ||
|
|
020db922b7 | ||
|
|
a67a11e933 | ||
|
|
8c620802f0 | ||
|
|
a31fb2ac6c | ||
|
|
a444325bd1 | ||
|
|
2f4b1341d2 | ||
|
|
7640944bb9 | ||
|
|
69c6b2f463 | ||
|
|
ef6f2389a0 | ||
|
|
1d4b97c619 | ||
|
|
0aed8c04c6 | ||
|
|
b1b6953aed | ||
|
|
b2ea5620b2 | ||
|
|
743b179b08 | ||
|
|
4553d04c38 | ||
|
|
4ca5e9eef8 | ||
|
|
7d7f929cfc | ||
|
|
085d685488 | ||
|
|
0458c548ec | ||
|
|
e5457f8bb7 | ||
|
|
46a49a134d | ||
|
|
25a9327b14 | ||
|
|
d0d38214c5 | ||
|
|
991c46d058 | ||
|
|
ca9bd8b499 | ||
|
|
199e25ec06 | ||
|
|
f38fb68d62 | ||
|
|
3a22d29d7b | ||
|
|
80642edf4f | ||
|
|
9a3e1058f6 | ||
|
|
26872c3c25 | ||
|
|
dba3f3d335 | ||
|
|
7f7c5f2381 | ||
|
|
8ee7c47fdf | ||
|
|
441a2bcf38 | ||
|
|
821f3f5884 | ||
|
|
ca70e8e9a6 | ||
|
|
70feb500f6 | ||
|
|
bf8b3d0546 | ||
|
|
07d8bc7852 | ||
|
|
b660b90adc | ||
|
|
5def006033 | ||
|
|
ade6acccfb | ||
|
|
bd795f5546 | ||
|
|
c12a560c63 | ||
|
|
ff1ca9d480 | ||
|
|
4b49080bc4 | ||
|
|
64e6b36d04 | ||
|
|
0e0360781e | ||
|
|
c62be10620 | ||
|
|
fa541a2604 | ||
|
|
5590b8d40b | ||
|
|
a32165016d | ||
|
|
57112258e6 | ||
|
|
0bec404e69 | ||
|
|
1d2fccfc0b | ||
|
|
cac9eed306 | ||
|
|
2bf12a6683 | ||
|
|
6205be5e73 | ||
|
|
38c44533b3 | ||
|
|
a6713b176b | ||
|
|
7805ccf176 | ||
|
|
2c13c4746b | ||
|
|
dfda8e6241 | ||
|
|
ec3a226a83 | ||
|
|
19dc26ea16 | ||
|
|
e29a384f90 | ||
|
|
088bbac543 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.90.0"
|
||||
__version__ = "15.95.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,7 @@
|
||||
"receivable_payable_remarks_length",
|
||||
"accounts_receivable_payable_tuning_section",
|
||||
"receivable_payable_fetch_method",
|
||||
"default_ageing_range",
|
||||
"column_break_ntmi",
|
||||
"drop_ar_procedures",
|
||||
"legacy_section",
|
||||
@@ -306,7 +307,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Learn about <a href=\"https://docs.erpnext.com/docs/v13/user/manual/en/accounts/articles/common_party_accounting#:~:text=Common%20Party%20Accounting%20in%20ERPNext,Invoice%20against%20a%20primary%20Supplier.\">Common Party</a>",
|
||||
"description": "Learn about <a href=\"https://docs.frappe.io/erpnext/user/manual/en/common_party_accounting\">Common Party</a>",
|
||||
"fieldname": "enable_common_party_accounting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Common Party Accounting"
|
||||
@@ -657,6 +658,12 @@
|
||||
"fieldname": "show_party_balance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Party Balance"
|
||||
},
|
||||
{
|
||||
"default": "30, 60, 90, 120",
|
||||
"fieldname": "default_ageing_range",
|
||||
"fieldtype": "Data",
|
||||
"label": "Default Ageing Range"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -664,7 +671,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-06 17:48:07.682837",
|
||||
"modified": "2025-12-26 19:46:55.093717",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
@@ -694,4 +701,4 @@
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ class AccountsSettings(Document):
|
||||
check_supplier_invoice_uniqueness: DF.Check
|
||||
create_pr_in_draft_status: DF.Check
|
||||
credit_controller: DF.Link | None
|
||||
default_ageing_range: DF.Data | None
|
||||
delete_linked_ledger_entries: DF.Check
|
||||
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
|
||||
enable_common_party_accounting: DF.Check
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
frappe.provide("erpnext.integrations");
|
||||
|
||||
frappe.ui.form.on("Bank", {
|
||||
onload: function (frm) {
|
||||
add_fields_to_mapping_table(frm);
|
||||
},
|
||||
refresh: function (frm) {
|
||||
add_fields_to_mapping_table(frm);
|
||||
frm.toggle_display(["address_html", "contact_html"], !frm.doc.__islocal);
|
||||
@@ -37,11 +34,11 @@ let add_fields_to_mapping_table = function (frm) {
|
||||
});
|
||||
});
|
||||
|
||||
frm.fields_dict.bank_transaction_mapping.grid.update_docfield_property(
|
||||
"bank_transaction_field",
|
||||
"options",
|
||||
options
|
||||
);
|
||||
const grid = frm.fields_dict.bank_transaction_mapping?.grid;
|
||||
|
||||
if (grid) {
|
||||
grid.update_docfield_property("bank_transaction_field", "options", options);
|
||||
}
|
||||
};
|
||||
|
||||
erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
|
||||
@@ -116,7 +113,7 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
|
||||
"There was an issue connecting to Plaid's authentication server. Check browser console for more information"
|
||||
)
|
||||
);
|
||||
console.log(error);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
plaid_success(token, response) {
|
||||
|
||||
@@ -304,6 +304,7 @@ def create_payment_entry_bts(
|
||||
project=None,
|
||||
cost_center=None,
|
||||
allow_edit=None,
|
||||
company_bank_account=None,
|
||||
):
|
||||
# Create a new payment entry based on the bank transaction
|
||||
bank_transaction = frappe.db.get_values(
|
||||
@@ -345,6 +346,9 @@ def create_payment_entry_bts(
|
||||
pe.project = project
|
||||
pe.cost_center = cost_center
|
||||
|
||||
if company_bank_account:
|
||||
pe.bank_account = company_bank_account
|
||||
|
||||
pe.validate()
|
||||
|
||||
if allow_edit:
|
||||
|
||||
@@ -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.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestBankTransactionFees(FrappeTestCase):
|
||||
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)
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ class ExchangeRateRevaluation(Document):
|
||||
company_currency = erpnext.get_company_currency(company)
|
||||
precision = get_field_precision(
|
||||
frappe.get_meta("Exchange Rate Revaluation Account").get_field("new_balance_in_base_currency"),
|
||||
company_currency,
|
||||
currency=company_currency,
|
||||
)
|
||||
|
||||
if account_details:
|
||||
@@ -486,6 +486,9 @@ class ExchangeRateRevaluation(Document):
|
||||
journal_entry.posting_date = self.posting_date
|
||||
journal_entry.multi_currency = 1
|
||||
|
||||
# Prevent JE from overriding user-entered exchange rates (e.g., rate of 1)
|
||||
journal_entry.flags.ignore_exchange_rate = True
|
||||
|
||||
journal_entry_accounts = []
|
||||
for d in accounts:
|
||||
if not flt(d.get("balance_in_account_currency"), d.precision("balance_in_account_currency")):
|
||||
|
||||
@@ -187,7 +187,6 @@ class GLEntry(Document):
|
||||
account_type == "Profit and Loss"
|
||||
and self.company == dimension.company
|
||||
and dimension.mandatory_for_pl
|
||||
and not dimension.disabled
|
||||
and not self.is_cancelled
|
||||
):
|
||||
if not self.get(dimension.fieldname):
|
||||
@@ -201,7 +200,6 @@ class GLEntry(Document):
|
||||
account_type == "Balance Sheet"
|
||||
and self.company == dimension.company
|
||||
and dimension.mandatory_for_bs
|
||||
and not dimension.disabled
|
||||
and not self.is_cancelled
|
||||
):
|
||||
if not self.get(dimension.fieldname):
|
||||
@@ -420,7 +418,7 @@ def update_against_account(voucher_type, voucher_no):
|
||||
if not entries:
|
||||
return
|
||||
company_currency = erpnext.get_company_currency(entries[0].company)
|
||||
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), company_currency)
|
||||
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), currency=company_currency)
|
||||
|
||||
accounts_debited, accounts_credited = [], []
|
||||
for d in entries:
|
||||
|
||||
@@ -20,6 +20,23 @@ frappe.ui.form.on("Journal Entry", {
|
||||
"Unreconcile Payment Entries",
|
||||
"Bank Transaction",
|
||||
];
|
||||
frm.trigger("set_queries");
|
||||
},
|
||||
|
||||
set_queries(frm) {
|
||||
frm.set_query("project", "accounts", function (doc, cdt, cdn) {
|
||||
let row = frappe.get_doc(cdt, cdn);
|
||||
let filters = {
|
||||
company: doc.company,
|
||||
};
|
||||
if (row.party_type == "Customer") {
|
||||
filters.customer = row.party;
|
||||
}
|
||||
return {
|
||||
query: "erpnext.controllers.queries.get_project_name",
|
||||
filters,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint, scrub
|
||||
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
|
||||
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
|
||||
|
||||
import erpnext
|
||||
@@ -33,6 +34,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):
|
||||
@@ -170,16 +172,17 @@ class JournalEntry(AccountsController):
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
|
||||
def submit(self):
|
||||
if len(self.accounts) > 100:
|
||||
msgprint(_("The task has been enqueued as a background job."), alert=True)
|
||||
self.queue_action("submit", timeout=4600)
|
||||
if len(self.accounts) > 100 and not self.meta.queue_in_background:
|
||||
queue_submission(self, "_submit")
|
||||
else:
|
||||
return self._submit()
|
||||
|
||||
def before_cancel(self):
|
||||
self.has_asset_adjustment_entry()
|
||||
|
||||
def cancel(self):
|
||||
if len(self.accounts) > 100:
|
||||
msgprint(_("The task has been enqueued as a background job."), alert=True)
|
||||
self.queue_action("cancel", timeout=4600)
|
||||
queue_submission(self, "_cancel")
|
||||
else:
|
||||
return self._cancel()
|
||||
|
||||
@@ -273,93 +276,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):
|
||||
if self.flags.planned_depr_entry or self.voucher_type != "Depreciation Entry":
|
||||
@@ -533,12 +450,27 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "")
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
frappe.db.sql(
|
||||
""" update `tabAsset Value Adjustment`
|
||||
set journal_entry = null where journal_entry = %s""",
|
||||
self.name,
|
||||
def has_asset_adjustment_entry(self):
|
||||
if self.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.name)
|
||||
).run()
|
||||
|
||||
def validate_party(self):
|
||||
for d in self.get("accounts"):
|
||||
@@ -1281,6 +1213,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):
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
|
||||
@@ -1649,8 +1805,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
|
||||
)
|
||||
@@ -1672,8 +1826,8 @@ 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 exchange_rate or flt(exchange_rate) == 1) and account_currency and posting_date:
|
||||
exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date)
|
||||
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)
|
||||
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",
|
||||
@@ -282,12 +283,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
|
||||
|
||||
@@ -400,6 +400,16 @@ frappe.ui.form.on("Payment Entry", {
|
||||
);
|
||||
|
||||
frm.refresh_fields();
|
||||
|
||||
const party_currency =
|
||||
frm.doc.payment_type === "Receive" ? "paid_from_account_currency" : "paid_to_account_currency";
|
||||
|
||||
var reference_grid = frm.fields_dict["references"].grid;
|
||||
["total_amount", "outstanding_amount", "allocated_amount"].forEach((fieldname) => {
|
||||
reference_grid.update_docfield_property(fieldname, "options", party_currency);
|
||||
});
|
||||
|
||||
reference_grid.refresh();
|
||||
},
|
||||
|
||||
show_general_ledger: function (frm) {
|
||||
@@ -435,6 +445,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
"paid_to",
|
||||
"references",
|
||||
"total_allocated_amount",
|
||||
"party_name",
|
||||
],
|
||||
function (i, field) {
|
||||
frm.set_value(field, null);
|
||||
@@ -1118,7 +1129,7 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
|
||||
await frm.call("allocate_amount_to_references", {
|
||||
paid_amount: paid_amount,
|
||||
paid_amount: flt(paid_amount),
|
||||
paid_amount_change: paid_amount_change,
|
||||
allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
|
||||
});
|
||||
@@ -1302,15 +1313,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;
|
||||
}
|
||||
|
||||
@@ -1521,18 +1531,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"
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
{
|
||||
"columns": 2,
|
||||
"fieldname": "total_amount",
|
||||
"fieldtype": "Float",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Grand Total",
|
||||
"print_hide": 1,
|
||||
@@ -77,7 +77,7 @@
|
||||
{
|
||||
"columns": 2,
|
||||
"fieldname": "outstanding_amount",
|
||||
"fieldtype": "Float",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Outstanding",
|
||||
"read_only": 1
|
||||
@@ -85,7 +85,7 @@
|
||||
{
|
||||
"columns": 2,
|
||||
"fieldname": "allocated_amount",
|
||||
"fieldtype": "Float",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Allocated"
|
||||
},
|
||||
@@ -174,7 +174,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-25 04:32:11.040025",
|
||||
"modified": "2026-01-05 14:18:03.286224",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Entry Reference",
|
||||
|
||||
@@ -18,12 +18,12 @@ class PaymentEntryReference(Document):
|
||||
account_type: DF.Data | None
|
||||
advance_voucher_no: DF.DynamicLink | None
|
||||
advance_voucher_type: DF.Link | None
|
||||
allocated_amount: DF.Float
|
||||
allocated_amount: DF.Currency
|
||||
bill_no: DF.Data | None
|
||||
due_date: DF.Date | None
|
||||
exchange_gain_loss: DF.Currency
|
||||
exchange_rate: DF.Float
|
||||
outstanding_amount: DF.Float
|
||||
outstanding_amount: DF.Currency
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
@@ -34,7 +34,7 @@ class PaymentEntryReference(Document):
|
||||
reconcile_effect_on: DF.Date | None
|
||||
reference_doctype: DF.Link
|
||||
reference_name: DF.DynamicLink
|
||||
total_amount: DF.Float
|
||||
total_amount: DF.Currency
|
||||
# end: auto-generated types
|
||||
|
||||
@property
|
||||
|
||||
@@ -132,6 +132,12 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"fieldname": "due_date",
|
||||
|
||||
@@ -38,6 +38,7 @@ class PaymentLedgerEntry(Document):
|
||||
amount_in_account_currency: DF.Currency
|
||||
company: DF.Link | None
|
||||
cost_center: DF.Link | None
|
||||
project: DF.Link | None
|
||||
delinked: DF.Check
|
||||
due_date: DF.Date | None
|
||||
finance_book: DF.Link | None
|
||||
@@ -133,7 +134,6 @@ class PaymentLedgerEntry(Document):
|
||||
account_type == "Profit and Loss"
|
||||
and self.company == dimension.company
|
||||
and dimension.mandatory_for_pl
|
||||
and not dimension.disabled
|
||||
):
|
||||
if not self.get(dimension.fieldname):
|
||||
frappe.throw(
|
||||
@@ -146,7 +146,6 @@ class PaymentLedgerEntry(Document):
|
||||
account_type == "Balance Sheet"
|
||||
and self.company == dimension.company
|
||||
and dimension.mandatory_for_bs
|
||||
and not dimension.disabled
|
||||
):
|
||||
if not self.get(dimension.fieldname):
|
||||
frappe.throw(
|
||||
|
||||
@@ -334,7 +334,9 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
|
||||
},
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
options: "<b> New Journal Entry will be posted for the difference amount </b>",
|
||||
options: __(
|
||||
"New Journal Entry will be posted for the difference amount. The Posting Date can be modified."
|
||||
).bold(),
|
||||
},
|
||||
],
|
||||
primary_action: () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
|
||||
|
||||
@@ -393,6 +393,9 @@ class PaymentReconciliation(Document):
|
||||
inv.outstanding_amount = flt(entry.get("outstanding_amount"))
|
||||
|
||||
def get_difference_amount(self, payment_entry, invoice, allocated_amount):
|
||||
party_account_defaults = frappe.get_cached_value(
|
||||
"Account", self.receivable_payable_account, ["account_type", "account_currency"], as_dict=True
|
||||
)
|
||||
allocated_amount_precision = get_field_precision(
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
|
||||
)
|
||||
@@ -400,9 +403,9 @@ class PaymentReconciliation(Document):
|
||||
frappe.get_meta("Payment Reconciliation Allocation").get_field("difference_amount")
|
||||
)
|
||||
difference_amount = 0
|
||||
if frappe.get_cached_value(
|
||||
"Account", self.receivable_payable_account, "account_currency"
|
||||
) != frappe.get_cached_value("Company", self.company, "default_currency"):
|
||||
if party_account_defaults.get("account_currency") != frappe.get_cached_value(
|
||||
"Company", self.company, "default_currency"
|
||||
):
|
||||
if invoice.get("exchange_rate") and payment_entry.get("exchange_rate", 1) != invoice.get(
|
||||
"exchange_rate", 1
|
||||
):
|
||||
@@ -414,7 +417,14 @@ class PaymentReconciliation(Document):
|
||||
invoice.get("exchange_rate", 1) * flt(allocated_amount, allocated_amount_precision),
|
||||
difference_amount_precision,
|
||||
)
|
||||
difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
|
||||
|
||||
# Added If clause to handle return Adhoc payments for account type holders ("Payable")
|
||||
if party_account_defaults.get("account_type") in ("Payable") and invoice.get(
|
||||
"invoice_type"
|
||||
) in ["Payment Entry", "Journal Entry"]:
|
||||
difference_amount = allocated_amount_in_inv_rate - allocated_amount_in_ref_rate
|
||||
else:
|
||||
difference_amount = allocated_amount_in_ref_rate - allocated_amount_in_inv_rate
|
||||
|
||||
return difference_amount
|
||||
|
||||
@@ -677,6 +687,28 @@ class PaymentReconciliation(Document):
|
||||
)
|
||||
invoice_exchange_map.update(journals_map)
|
||||
|
||||
payment_entries = [
|
||||
d.get("invoice_number") for d in invoices if d.get("invoice_type") == "Payment Entry"
|
||||
]
|
||||
payment_entries.extend(
|
||||
[d.get("reference_name") for d in payments if d.get("reference_type") == "Payment Entry"]
|
||||
)
|
||||
if payment_entries:
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
query = (
|
||||
frappe.qb.from_(pe)
|
||||
.select(
|
||||
pe.name,
|
||||
Case()
|
||||
.when(pe.payment_type == "Receive", pe.source_exchange_rate)
|
||||
.else_(pe.target_exchange_rate)
|
||||
.as_("exchange_rate"),
|
||||
)
|
||||
.where(pe.name.isin(payment_entries))
|
||||
)
|
||||
payment_entries = query.run(as_list=1)
|
||||
invoice_exchange_map.update(payment_entries)
|
||||
|
||||
return invoice_exchange_map
|
||||
|
||||
def validate_allocation(self):
|
||||
@@ -714,7 +746,7 @@ class PaymentReconciliation(Document):
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
|
||||
|
||||
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
|
||||
@@ -765,6 +797,14 @@ class PaymentReconciliation(Document):
|
||||
|
||||
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
|
||||
for inv in dr_cr_notes:
|
||||
if (
|
||||
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
|
||||
< inv.allocated_amount
|
||||
):
|
||||
frappe.throw(
|
||||
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)
|
||||
)
|
||||
|
||||
voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note"
|
||||
|
||||
reconcile_dr_or_cr = (
|
||||
|
||||
@@ -2336,6 +2336,210 @@ class TestPaymentReconciliation(FrappeTestCase):
|
||||
|
||||
frappe.db.set_value("Company", self.company, default_settings)
|
||||
|
||||
def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_customer(self):
|
||||
transaction_date = nowdate()
|
||||
customer = self.customer3
|
||||
amount = 1000
|
||||
exchange_rate_at_payment = 100
|
||||
exchange_rate_at_reverse_payment = 95
|
||||
|
||||
# Receive amount from customer - 1,00,000
|
||||
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date, customer=customer)
|
||||
pe.payment_type = "Receive"
|
||||
pe.paid_from = self.debtors_eur
|
||||
pe.paid_from_account_currency = "EUR"
|
||||
pe.source_exchange_rate = exchange_rate_at_payment
|
||||
pe.paid_amount = amount
|
||||
pe.received_amount = exchange_rate_at_payment * amount
|
||||
pe.paid_to = self.cash
|
||||
pe.paid_to_account_currency = "INR"
|
||||
pe = pe.save().submit()
|
||||
|
||||
# Pay amount to customer - 95,000
|
||||
reverse_pe = self.create_payment_entry(
|
||||
amount=amount, posting_date=transaction_date, customer=customer
|
||||
)
|
||||
reverse_pe.payment_type = "Pay"
|
||||
reverse_pe.paid_from = self.cash
|
||||
reverse_pe.paid_from_account_currency = "INR"
|
||||
reverse_pe.target_exchange_rate = exchange_rate_at_reverse_payment
|
||||
reverse_pe.paid_amount = exchange_rate_at_reverse_payment * amount
|
||||
reverse_pe.received_amount = amount
|
||||
reverse_pe.paid_to = self.debtors_eur
|
||||
reverse_pe.paid_to_account_currency = "EUR"
|
||||
reverse_pe.save().submit()
|
||||
|
||||
# Reconcile payments
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.party = customer
|
||||
pr.receivable_payable_account = self.debtors_eur
|
||||
pr.get_unreconciled_entries()
|
||||
invoices = [invoice.as_dict() for invoice in pr.invoices]
|
||||
payments = [payment.as_dict() for payment in pr.payments]
|
||||
self.assertEqual(len(pr.get("invoices")), 1)
|
||||
self.assertEqual(len(pr.get("payments")), 1)
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Check the difference_amount is a gain of 5000
|
||||
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), 5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_foreign_currency_reverse_payment_entry_against_payment_entry_for_supplier(self):
|
||||
transaction_date = nowdate()
|
||||
self.supplier = "_Test Supplier USD"
|
||||
amount = 1000
|
||||
exchange_rate_at_payment = 100
|
||||
exchange_rate_at_reverse_payment = 95
|
||||
|
||||
# Pay amount to supplier - 1,00,000
|
||||
pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
pe.payment_type = "Pay"
|
||||
pe.party_type = "Supplier"
|
||||
pe.party = self.supplier
|
||||
pe.paid_from = self.cash
|
||||
pe.paid_from_account_currency = "INR"
|
||||
pe.target_exchange_rate = exchange_rate_at_payment
|
||||
pe.paid_amount = exchange_rate_at_payment * amount
|
||||
pe.received_amount = amount
|
||||
pe.paid_to = self.creditors_usd
|
||||
pe.paid_to_account_currency = "USD"
|
||||
pe.save().submit()
|
||||
|
||||
# Receive amount from supplier - 95,000
|
||||
reverse_pe = self.create_payment_entry(amount=amount, posting_date=transaction_date)
|
||||
reverse_pe.payment_type = "Receive"
|
||||
reverse_pe.party_type = "Supplier"
|
||||
reverse_pe.party = self.supplier
|
||||
reverse_pe.paid_from = self.creditors_usd
|
||||
reverse_pe.paid_from_account_currency = "USD"
|
||||
reverse_pe.source_exchange_rate = exchange_rate_at_reverse_payment
|
||||
reverse_pe.paid_amount = amount
|
||||
reverse_pe.received_amount = exchange_rate_at_reverse_payment * amount
|
||||
reverse_pe.paid_to = self.cash
|
||||
reverse_pe.paid_to_account_currency = "INR"
|
||||
reverse_pe = reverse_pe.save().submit()
|
||||
|
||||
# Reconcile payments
|
||||
pr = self.create_payment_reconciliation(party_is_customer=False)
|
||||
pr.party = self.supplier
|
||||
pr.receivable_payable_account = self.creditors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
invoices = [invoice.as_dict() for invoice in pr.invoices]
|
||||
payments = [payment.as_dict() for payment in pr.payments]
|
||||
|
||||
self.assertEqual(len(pr.get("invoices")), 1)
|
||||
self.assertEqual(len(pr.get("payments")), 1)
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Check the difference_amount is a loss of 5000
|
||||
self.assertEqual(flt(pr.allocation[0].get("difference_amount")), -5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_customer(self):
|
||||
transaction_date = nowdate()
|
||||
customer = self.customer3
|
||||
amount = 1000
|
||||
exchange_rate_at_payment = 95
|
||||
exchange_rate_at_reverse_payment = 100
|
||||
|
||||
# Receive amount from customer - 95,000
|
||||
je1 = self.create_journal_entry(self.cash, self.debtors_eur, amount, transaction_date)
|
||||
je1.multi_currency = 1
|
||||
je1.accounts[0].exchange_rate = 1
|
||||
je1.accounts[0].debit_in_account_currency = exchange_rate_at_payment * amount
|
||||
je1.accounts[0].debit = exchange_rate_at_payment * amount
|
||||
je1.accounts[1].party_type = "Customer"
|
||||
je1.accounts[1].party = customer
|
||||
je1.accounts[1].exchange_rate = exchange_rate_at_payment
|
||||
je1.accounts[1].credit_in_account_currency = amount
|
||||
je1.accounts[1].credit = exchange_rate_at_payment * amount
|
||||
je1.save()
|
||||
je1.submit()
|
||||
|
||||
# Pay amount to customer - 1,00,000
|
||||
je2 = self.create_journal_entry(self.debtors_eur, self.cash, amount, transaction_date)
|
||||
je2.multi_currency = 1
|
||||
je2.accounts[0].party_type = "Customer"
|
||||
je2.accounts[0].party = customer
|
||||
je2.accounts[0].exchange_rate = exchange_rate_at_reverse_payment
|
||||
je2.accounts[0].debit_in_account_currency = amount
|
||||
je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
|
||||
je2.accounts[1].exchange_rate = 1
|
||||
je2.accounts[1].credit_in_account_currency = exchange_rate_at_reverse_payment * amount
|
||||
je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
|
||||
je2.save()
|
||||
je2.submit()
|
||||
|
||||
# Reconcile payments
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.party = customer
|
||||
pr.receivable_payable_account = self.debtors_eur
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
self.assertEqual(len(pr.invoices), 1)
|
||||
self.assertEqual(len(pr.payments), 1)
|
||||
|
||||
invoices = [invoice.as_dict() for invoice in pr.invoices]
|
||||
payments = [payment.as_dict() for payment in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Check the difference_amount is a loss of 5000
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), -5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
def test_foreign_currency_reverse_journal_entry_against_journal_entry_for_supplier(self):
|
||||
transaction_date = nowdate()
|
||||
self.supplier = "_Test Supplier USD"
|
||||
amount = 1000
|
||||
exchange_rate_at_payment = 95
|
||||
exchange_rate_at_reverse_payment = 100
|
||||
|
||||
# Pay amount to supplier - 95,000
|
||||
je1 = self.create_journal_entry(self.creditors_usd, self.cash, amount, transaction_date)
|
||||
je1.multi_currency = 1
|
||||
je1.accounts[0].party_type = "Supplier"
|
||||
je1.accounts[0].party = self.supplier
|
||||
je1.accounts[0].exchange_rate = exchange_rate_at_payment
|
||||
je1.accounts[0].debit_in_account_currency = amount
|
||||
je1.accounts[0].debit = exchange_rate_at_payment * amount
|
||||
je1.accounts[1].exchange_rate = 1
|
||||
je1.accounts[1].credit = exchange_rate_at_payment * amount
|
||||
je1.accounts[1].credit_in_account_currency = exchange_rate_at_payment * amount
|
||||
je1.save()
|
||||
je1.submit()
|
||||
|
||||
# Receive amount from supplier - 1,00,000
|
||||
je2 = self.create_journal_entry(self.cash, self.creditors_usd, amount, transaction_date)
|
||||
je2.multi_currency = 1
|
||||
je2.accounts[0].exchange_rate = 1
|
||||
je2.accounts[0].debit = exchange_rate_at_reverse_payment * amount
|
||||
je2.accounts[0].debit_in_account_currency = exchange_rate_at_reverse_payment * amount
|
||||
je2.accounts[1].party_type = "Supplier"
|
||||
je2.accounts[1].party = self.supplier
|
||||
je2.accounts[1].exchange_rate = exchange_rate_at_reverse_payment
|
||||
je2.accounts[1].credit_in_account_currency = amount
|
||||
je2.accounts[1].credit = exchange_rate_at_reverse_payment * amount
|
||||
je2.save()
|
||||
je2.submit()
|
||||
|
||||
# Reconcile payments
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.party_type = "Supplier"
|
||||
pr.party = self.supplier
|
||||
pr.receivable_payable_account = self.creditors_usd
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
self.assertEqual(len(pr.invoices), 1)
|
||||
self.assertEqual(len(pr.payments), 1)
|
||||
|
||||
invoices = [invoice.as_dict() for invoice in pr.invoices]
|
||||
payments = [payment.as_dict() for payment in pr.payments]
|
||||
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
|
||||
|
||||
# Check the difference_amount is a gain of 5000
|
||||
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
|
||||
pr.reconcile()
|
||||
|
||||
|
||||
def make_customer(customer_name, currency=None):
|
||||
if not frappe.db.exists("Customer", customer_name):
|
||||
|
||||
@@ -545,6 +545,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
|
||||
@@ -850,6 +853,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:
|
||||
@@ -875,7 +879,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:
|
||||
@@ -883,12 +887,15 @@ 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):
|
||||
|
||||
@@ -13,9 +13,9 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
return {
|
||||
filters: [
|
||||
["Account", "company", "=", frm.doc.company],
|
||||
["Account", "is_group", "=", "0"],
|
||||
["Account", "is_group", "=", 0],
|
||||
["Account", "freeze_account", "=", "No"],
|
||||
["Account", "root_type", "in", "Liability, Equity"],
|
||||
["Account", "root_type", "in", ["Liability", "Equity"]],
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -18,12 +18,17 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
from erpnext.accounts.party import get_due_date, get_party_account
|
||||
from erpnext.controllers.queries import item_query as _item_query
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
||||
|
||||
|
||||
class PartialPaymentValidationError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class ProductBundleStockValidationError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class POSInvoice(SalesInvoice):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
@@ -350,32 +355,67 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.serial_and_batch_bundle:
|
||||
available_stock, is_stock_item, is_negative_stock_allowed = get_stock_availability(
|
||||
d.item_code, d.warehouse
|
||||
)
|
||||
if frappe.db.exists("Product Bundle", d.item_code):
|
||||
(
|
||||
availability,
|
||||
is_stock_item,
|
||||
is_negative_stock_allowed,
|
||||
) = get_product_bundle_stock_availability(d.item_code, d.warehouse, d.stock_qty)
|
||||
|
||||
else:
|
||||
availability, is_stock_item, is_negative_stock_allowed = get_stock_availability(
|
||||
d.item_code, d.warehouse
|
||||
)
|
||||
|
||||
if is_negative_stock_allowed:
|
||||
continue
|
||||
|
||||
item_code, warehouse, _qty = (
|
||||
frappe.bold(d.item_code),
|
||||
frappe.bold(d.warehouse),
|
||||
frappe.bold(d.qty),
|
||||
)
|
||||
if is_stock_item and flt(available_stock) <= 0:
|
||||
frappe.throw(
|
||||
_("Row #{}: Item Code: {} is not available under warehouse {}.").format(
|
||||
d.idx, item_code, warehouse
|
||||
),
|
||||
title=_("Item Unavailable"),
|
||||
)
|
||||
elif is_stock_item and flt(available_stock) < flt(d.stock_qty):
|
||||
frappe.throw(
|
||||
_("Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}.").format(
|
||||
d.idx, item_code, warehouse
|
||||
),
|
||||
title=_("Item Unavailable"),
|
||||
)
|
||||
if isinstance(availability, list):
|
||||
error_msgs = []
|
||||
for item in availability:
|
||||
if flt(item["available"]) < flt(item["required"]):
|
||||
error_msgs.append(
|
||||
_("<li>Packed Item {0}: Required {1}, Available {2}</li>").format(
|
||||
frappe.bold(item["item_code"]),
|
||||
frappe.bold(flt(item["required"], 2)),
|
||||
frappe.bold(flt(item["available"], 2)),
|
||||
)
|
||||
)
|
||||
|
||||
if error_msgs:
|
||||
frappe.throw(
|
||||
_(
|
||||
"<b>Row #{0}:</b> Bundle {1} in warehouse {2} has insufficient packed items:<br><div style='margin-top: 15px;'><ul style='line-height: 0.8;'>{3}</ul></div>"
|
||||
).format(
|
||||
d.idx,
|
||||
frappe.bold(d.item_code),
|
||||
frappe.bold(d.warehouse),
|
||||
"<br>".join(error_msgs),
|
||||
),
|
||||
title=_("Insufficient Stock for Product Bundle Items"),
|
||||
exc=ProductBundleStockValidationError,
|
||||
)
|
||||
|
||||
else:
|
||||
item_code, warehouse = frappe.bold(d.item_code), frappe.bold(d.warehouse)
|
||||
if is_stock_item and flt(availability) <= 0:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Item {1} has no stock in warehouse {2}.").format(
|
||||
d.idx, item_code, warehouse
|
||||
),
|
||||
title=_("Item Out of Stock"),
|
||||
)
|
||||
elif is_stock_item and flt(availability) < flt(d.stock_qty):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Item {1} in warehouse {2}: Available {3}, Needed {4}.").format(
|
||||
d.idx,
|
||||
item_code,
|
||||
warehouse,
|
||||
frappe.bold(flt(availability, 2)),
|
||||
frappe.bold(flt(d.stock_qty, 2)),
|
||||
),
|
||||
title=_("Insufficient Stock"),
|
||||
)
|
||||
|
||||
def validate_serialised_or_batched_item(self):
|
||||
error_msg = []
|
||||
@@ -763,8 +803,6 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_stock_availability(item_code, warehouse):
|
||||
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
||||
|
||||
if frappe.db.get_value("Item", item_code, "is_stock_item"):
|
||||
is_stock_item = True
|
||||
bin_qty = get_bin_qty(item_code, warehouse)
|
||||
@@ -781,6 +819,26 @@ def get_stock_availability(item_code, warehouse):
|
||||
return 0, is_stock_item, False
|
||||
|
||||
|
||||
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
is_stock_item = True
|
||||
bundle = frappe.get_doc("Product Bundle", item_code)
|
||||
availabilities = []
|
||||
for bundle_item in bundle.items:
|
||||
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
|
||||
bin_qty = get_bin_qty(bundle_item.item_code, warehouse)
|
||||
reserved_qty = get_pos_reserved_qty(bundle_item.item_code, warehouse)
|
||||
available = bin_qty - reserved_qty
|
||||
availabilities.append(
|
||||
{
|
||||
"item_code": bundle_item.item_code,
|
||||
"required": bundle_item.qty * item_qty,
|
||||
"available": available,
|
||||
}
|
||||
)
|
||||
|
||||
return availabilities, is_stock_item, is_negative_stock_allowed(item_code=item_code)
|
||||
|
||||
|
||||
def get_bundle_availability(bundle_item_code, warehouse):
|
||||
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
|
||||
|
||||
|
||||
@@ -481,6 +481,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
rate=1000,
|
||||
serial_no=[serial_nos[0]],
|
||||
do_not_save=1,
|
||||
ignore_sabb_validation=True,
|
||||
)
|
||||
|
||||
pos2.append("payments", {"mode_of_payment": "Bank Draft", "amount": 1000})
|
||||
@@ -956,6 +957,7 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
qty=1,
|
||||
rate=100,
|
||||
do_not_submit=True,
|
||||
ignore_sabb_validation=True,
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pos_inv.submit)
|
||||
@@ -964,6 +966,84 @@ class TestPOSInvoice(unittest.TestCase):
|
||||
frappe.db.rollback(save_point="before_test_delivered_serial_no_case")
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_bundle_stock_availability_validation(self):
|
||||
from erpnext.accounts.doctype.pos_invoice.pos_invoice import ProductBundleStockValidationError
|
||||
from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import (
|
||||
init_user_and_profile,
|
||||
)
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
|
||||
init_user_and_profile()
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
company = "_Test Company"
|
||||
|
||||
# Create stock sub-items
|
||||
sub_item_a = "_Test Bundle SubA"
|
||||
if not frappe.db.exists("Item", sub_item_a):
|
||||
create_item(
|
||||
item_code=sub_item_a,
|
||||
is_stock_item=1,
|
||||
)
|
||||
|
||||
sub_item_b = "_Test Bundle SubB"
|
||||
if not frappe.db.exists("Item", sub_item_b):
|
||||
create_item(
|
||||
item_code=sub_item_b,
|
||||
is_stock_item=1,
|
||||
)
|
||||
|
||||
# Add initial stock: SubA=5, SubB=2
|
||||
make_stock_entry(item_code=sub_item_a, target=warehouse, qty=5, company=company)
|
||||
make_stock_entry(item_code=sub_item_b, target=warehouse, qty=2, company=company)
|
||||
|
||||
# Create Product Bundle: Test Bundle (SubA x2 + SubB x1)
|
||||
bundle_item = "_Test Bundle"
|
||||
if not frappe.db.exists("Item", bundle_item):
|
||||
create_item(
|
||||
item_code=bundle_item,
|
||||
is_stock_item=0,
|
||||
)
|
||||
|
||||
if not frappe.db.exists("Product Bundle", bundle_item):
|
||||
make_product_bundle(parent=bundle_item, items=[sub_item_a, sub_item_b])
|
||||
|
||||
# Test Case 1: Sufficient stock (bundle qty=1: requires SubA=2 (<=5), SubB=1 (<=2)) -> No error
|
||||
pos_inv_sufficient = create_pos_invoice(
|
||||
item=bundle_item,
|
||||
qty=1,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
pos_profile=self.pos_profile.name,
|
||||
do_not_save=1,
|
||||
)
|
||||
pos_inv_sufficient.append("payments", {"mode_of_payment": "Cash", "amount": 100, "default": 1})
|
||||
pos_inv_sufficient.insert()
|
||||
pos_inv_sufficient.submit()
|
||||
|
||||
pos_inv_sufficient.cancel()
|
||||
pos_inv_sufficient.delete()
|
||||
|
||||
# Test Case 2: Insufficient stock (reduce SubB to 1, bundle qty=2: requires SubB=2 >1) -> Error with details
|
||||
make_stock_entry(item_code=sub_item_b, from_warehouse=warehouse, qty=1, company=company)
|
||||
|
||||
pos_inv_insufficient = create_pos_invoice(
|
||||
item=bundle_item,
|
||||
qty=2,
|
||||
rate=100,
|
||||
warehouse=warehouse,
|
||||
pos_profile=self.pos_profile.name,
|
||||
do_not_save=1,
|
||||
)
|
||||
pos_inv_insufficient.append("payments", {"mode_of_payment": "Cash", "amount": 200, "default": 1})
|
||||
pos_inv_insufficient.save()
|
||||
self.assertRaises(ProductBundleStockValidationError, pos_inv_insufficient.submit)
|
||||
|
||||
frappe.set_user("test@example.com")
|
||||
|
||||
|
||||
def create_pos_invoice(**args):
|
||||
args = frappe._dict(args)
|
||||
@@ -1019,6 +1099,7 @@ def create_pos_invoice(**args):
|
||||
"posting_time": pos_inv.posting_time,
|
||||
"type_of_transaction": type_of_transaction,
|
||||
"do_not_submit": True,
|
||||
"ignore_sabb_validation": args.ignore_sabb_validation,
|
||||
}
|
||||
)
|
||||
).name
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2 class="text-center">{{ _("STATEMENTS OF ACCOUNTS") }}</h2>
|
||||
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
|
||||
<div>
|
||||
{% if filters.party[0] == filters.party_name[0] %}
|
||||
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "Prompt",
|
||||
"creation": "2020-05-22 16:46:18.712954",
|
||||
"doctype": "DocType",
|
||||
@@ -67,7 +68,7 @@
|
||||
"fieldname": "frequency",
|
||||
"fieldtype": "Select",
|
||||
"label": "Frequency",
|
||||
"options": "Weekly\nMonthly\nQuarterly"
|
||||
"options": "Daily\nWeekly\nBiweekly\nMonthly\nQuarterly"
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
@@ -401,7 +402,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-08-04 18:21:12.603623",
|
||||
"modified": "2025-10-07 12:19:20.719898",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Statement Of Accounts",
|
||||
|
||||
@@ -8,7 +8,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.reportview import get_match_cond
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_days, add_months, format_date, getdate, today
|
||||
from frappe.utils import add_days, add_months, add_to_date, format_date, getdate, today
|
||||
from frappe.utils.jinja import validate_template
|
||||
from frappe.utils.pdf import get_pdf
|
||||
from frappe.www.printview import get_print_style
|
||||
@@ -55,7 +55,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
enable_auto_email: DF.Check
|
||||
filter_duration: DF.Int
|
||||
finance_book: DF.Link | None
|
||||
frequency: DF.Literal["Weekly", "Monthly", "Quarterly"]
|
||||
frequency: DF.Literal["Daily", "Weekly", "Biweekly", "Monthly", "Quarterly"]
|
||||
from_date: DF.Date | None
|
||||
ignore_cr_dr_notes: DF.Check
|
||||
ignore_exchange_rate_revaluation_journals: DF.Check
|
||||
@@ -529,8 +529,9 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
|
||||
|
||||
if doc.enable_auto_email and from_scheduler:
|
||||
new_to_date = getdate(posting_date or today())
|
||||
if doc.frequency == "Weekly":
|
||||
new_to_date = add_days(new_to_date, 7)
|
||||
if doc.frequency in ("Daily", "Weekly", "Biweekly"):
|
||||
frequency = {"Daily": 1, "Weekly": 7, "Biweekly": 14}
|
||||
new_to_date = add_days(new_to_date, frequency[doc.frequency])
|
||||
else:
|
||||
new_to_date = add_months(new_to_date, 1 if doc.frequency == "Monthly" else 3)
|
||||
new_from_date = add_months(new_to_date, -1 * doc.filter_duration)
|
||||
|
||||
@@ -6,228 +6,304 @@
|
||||
.print-format td {
|
||||
vertical-align:middle !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
<div id="header-html" class="hidden-pdf">
|
||||
{% if letter_head.content %}
|
||||
<div class="letter-head text-center">{{ letter_head.content }}</div>
|
||||
<hr style="height:2px;border-width:0;color:black;background-color:black;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="footer-html" class="visible-pdf">
|
||||
{% if letter_head.footer %}
|
||||
<div class="letter-head-footer">
|
||||
<hr style="border-width:0;color:black;background-color:black;padding-bottom:2px;">
|
||||
{{ letter_head.footer }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="text-center" style="margin-top:0">{{ _(report.report_name) }}</h2>
|
||||
<h4 class="text-center">
|
||||
{{ filters.customer_name }}
|
||||
</h4>
|
||||
<h6 class="text-center">
|
||||
{% if (filters.tax_id) %}
|
||||
{{ _("Tax Id: ") }}{{ filters.tax_id }}
|
||||
{% endif %}
|
||||
</h6>
|
||||
<h5 class="text-center">
|
||||
{{ _(filters.ageing_based_on) }}
|
||||
{{ _("Until") }}
|
||||
{{ frappe.format(filters.report_date, 'Date') }}
|
||||
</h5>
|
||||
|
||||
<div class="clearfix">
|
||||
<div class="pull-left">
|
||||
{% if(filters.payment_terms) %}
|
||||
<strong>{{ _("Payment Terms") }}:</strong> {{ filters.payment_terms }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if(filters.credit_limit) %}
|
||||
<strong>{{ _("Credit Limit") }}:</strong> {{ frappe.utils.fmt_money(filters.credit_limit) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if(filters.show_future_payments) %}
|
||||
{% set balance_row = data.slice(-1).pop() %}
|
||||
{% for i in report.columns %}
|
||||
{% if i.fieldname == 'age' %}
|
||||
{% set elem = i %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set start = report.columns.findIndex(elem) %}
|
||||
{% set range1 = report.columns[start].label %}
|
||||
{% set range2 = report.columns[start+1].label %}
|
||||
{% set range3 = report.columns[start+2].label %}
|
||||
{% set range4 = report.columns[start+3].label %}
|
||||
{% set range5 = report.columns[start+4].label %}
|
||||
{% set range6 = report.columns[start+5].label %}
|
||||
|
||||
{% if(balance_row) %}
|
||||
<table class="table table-bordered table-condensed">
|
||||
<caption class="text-right">(Amount in {{ data[0]["currency"] ~ "" }})</caption>
|
||||
<colgroup>
|
||||
<col style="width: 30mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _(" ") }}</th>
|
||||
<th>{{ _(range1) }}</th>
|
||||
<th>{{ _(range2) }}</th>
|
||||
<th>{{ _(range3) }}</th>
|
||||
<th>{{ _(range4) }}</th>
|
||||
<th>{{ _(range5) }}</th>
|
||||
<th>{{ _(range6) }}</th>
|
||||
<th>{{ _("Total") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ _("Total Outstanding") }}</td>
|
||||
<td class="text-right">
|
||||
{{ format_number(balance_row["age"], null, 2) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range1"], data[data.length-1]["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range2"], data[data.length-1]["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range3"], data[data.length-1]["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range4"], data[data.length-1]["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range5"], data[data.length-1]["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(flt(balance_row["outstanding"]), data[data.length-1]["currency"]) }}
|
||||
</td>
|
||||
</tr>
|
||||
<td>{{ _("Future Payments") }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(flt(balance_row[("future_amount")]), data[data.length-1]["currency"]) }}
|
||||
</td>
|
||||
<tr class="cvs-footer">
|
||||
<th class="text-left">{{ _("Cheques Required") }}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="text-right">
|
||||
{{ frappe.utils.fmt_money(flt(balance_row["outstanding"] - balance_row[("future_amount")]), data[data.length-1]["currency"]) }}</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
{% endif %}
|
||||
<div id="header-html" class="hidden-pdf">
|
||||
{% if letter_head.content %}
|
||||
<div class="letter-head text-center">{{ letter_head.content }}</div>
|
||||
<hr style="height:2px;border-width:0;color:black;background-color:black;">
|
||||
{% endif %}
|
||||
<table class="table table-bordered">
|
||||
</div>
|
||||
<div id="footer-html" class="visible-pdf">
|
||||
{% if letter_head.footer %}
|
||||
<div class="letter-head-footer">
|
||||
<hr style="border-width:0;color:black;background-color:black;padding-bottom:2px;">
|
||||
{{ letter_head.footer }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="text-center" style="margin-top:0">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
|
||||
<h4 class="text-center">
|
||||
{{ filters.customer_name }}
|
||||
</h4>
|
||||
<h6 class="text-center">
|
||||
{% if (filters.tax_id) %}
|
||||
{{ _("Tax Id: {0}").format(filters.tax_id) }}
|
||||
{% endif %}
|
||||
</h6>
|
||||
<h5 class="text-center">
|
||||
{{ _("{0} until {1}").format(
|
||||
_(filters.ageing_based_on),
|
||||
frappe.format(filters.report_date, 'Date')
|
||||
) }}
|
||||
</h5>
|
||||
|
||||
<div class="clearfix">
|
||||
<div class="pull-left">
|
||||
{% if(filters.payment_terms) %}
|
||||
<strong>{{ _("Payment Terms:") }}</strong> {{ filters.payment_terms }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if(filters.credit_limit) %}
|
||||
<strong>{{ _("Credit Limit:") }}</strong> {{ frappe.utils.fmt_money(filters.credit_limit) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if(filters.show_future_payments)%}
|
||||
{% set balance_row = data[-1] %}
|
||||
|
||||
{% set ns = namespace(idx=None) %}
|
||||
{% for i in report.columns %}
|
||||
{% if i.fieldname == "age" and ns.idx is none %}
|
||||
{% set ns.idx = loop.index0 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set age = report.columns[ns.idx].label %}
|
||||
{% set range1 = report.columns[ns.idx+1].label %}
|
||||
{% set range2 = report.columns[ns.idx+2].label %}
|
||||
{% set range3 = report.columns[ns.idx+3].label %}
|
||||
{% set range4 = report.columns[ns.idx+4].label %}
|
||||
{% set range5 = report.columns[ns.idx+5].label %}
|
||||
|
||||
{% if(balance_row) %}
|
||||
<table class="table table-bordered table-condensed">
|
||||
<caption class="text-right">{{ _("Amount in {0}").format(data[0]["currency"] ~ "") }}</caption>
|
||||
<colgroup>
|
||||
<col style="width: 30mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
<col style="width: 18mm;">
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr>
|
||||
{% if(report.report_name == "Accounts Receivable" or report.report_name == "Accounts Payable") %}
|
||||
<th style="width: 10%">{{ _("Date") }}</th>
|
||||
<th style="width: 4%">{{ _("Age (Days)") }}</th>
|
||||
|
||||
{% if(report.report_name == "Accounts Receivable" and filters.show_sales_person) %}
|
||||
<th style="width: 14%">{{ _("Reference") }}</th>
|
||||
<th style="width: 10%">{{ _("Sales Person") }}</th>
|
||||
{% else %}
|
||||
<th style="width: 24%">{{ _("Reference") }}</th>
|
||||
{% endif %}
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<th style="width: 20%">
|
||||
{% if (filters.customer or filters.supplier or filters.customer_name) %}
|
||||
{{ _("Remarks") }}
|
||||
{% else %}
|
||||
{{ _("Party") }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endif %}
|
||||
<th style="width: 10%; text-align: right">{{ _("Invoiced Amount") }}</th>
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<th style="width: 10%; text-align: right">{{ _("Paid Amount") }}</th>
|
||||
<th style="width: 10%; text-align: right">
|
||||
{% if report.report_name == "Accounts Receivable" %}
|
||||
{{ _('Credit Note') }}
|
||||
{% else %}
|
||||
{{ _('Debit Note') }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endif %}
|
||||
<th style="width: 10%; text-align: right">{{ _("Outstanding Amount") }}</th>
|
||||
{% if(filters.show_future_payments) %}
|
||||
{% if(report.report_name == "Accounts Receivable") %}
|
||||
<th style="width: 12%">{{ _("Customer LPO No.") }}</th>
|
||||
{% endif %}
|
||||
<th style="width: 10%">{{ _("Future Payment Ref") }}</th>
|
||||
<th style="width: 10%">{{ _("Future Payment Amount") }}</th>
|
||||
<th style="width: 10%">{{ _("Remaining Balance") }}</th>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<th style="width: 40%">
|
||||
{% if (filters.customer or filters.supplier or filters.customer_name) %}
|
||||
{{ _("Remarks")}}
|
||||
{% else %}
|
||||
{{ _("Party") }}
|
||||
{% endif %}
|
||||
</th>
|
||||
<th style="width: 15%">{{ _("Total Invoiced Amount") }}</th>
|
||||
<th style="width: 15%">{{ _("Total Paid Amount") }}</th>
|
||||
<th style="width: 15%">
|
||||
{% if report.report_name == "Accounts Receivable Summary" %}
|
||||
{{ _('Credit Note Amount') }}
|
||||
{% else %}
|
||||
{{ _('Debit Note Amount') }}
|
||||
{% endif %}
|
||||
</th>
|
||||
<th style="width: 15%">{{ _("Total Outstanding Amount") }}</th>
|
||||
{% endif %}
|
||||
<th>{{ _(" ") }}</th>
|
||||
<th>{{ _(age) }}</th>
|
||||
<th>{{ _(range1) }}</th>
|
||||
<th>{{ _(range2) }}</th>
|
||||
<th>{{ _(range3) }}</th>
|
||||
<th>{{ _(range4) }}</th>
|
||||
<th>{{ _(range5) }}</th>
|
||||
<th>{{ _("Total") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in range(data|length) %}
|
||||
<tr>
|
||||
{% if(report.report_name == "Accounts Receivable" or report.report_name == "Accounts Payable") %}
|
||||
{% if(data[i]["party"]) %}
|
||||
<td>{{ frappe.format((data[i]["posting_date"]), 'Date') }}</td>
|
||||
<td style="text-align: right">{{ data[i]["age"] }}</td>
|
||||
<td>
|
||||
{% if not(filters.show_future_payments) %}
|
||||
{{ data[i]["voucher_type"] }}
|
||||
<br>
|
||||
{% endif %}
|
||||
{{ data[i]["voucher_no"] }}
|
||||
</td>
|
||||
<tr>
|
||||
<td>{{ _("Total Outstanding") }}</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.flt(balance_row["age"], 2) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range1"], currency=balance_row["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range2"], currency=balance_row["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range3"], currency=balance_row["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range4"], currency=balance_row["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(balance_row["range5"], currency=balance_row["currency"]) }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(frappe.utils.flt(balance_row["outstanding"]), currency=balance_row["currency"]) }}
|
||||
</td>
|
||||
</tr>
|
||||
<td>{{ _("Future Payments") }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="text-right">
|
||||
{{ frappe.utils.fmt_money(frappe.utils.flt(balance_row["future_amount"]), currency=balance_row["currency"]) }}
|
||||
</td>
|
||||
<tr class="cvs-footer">
|
||||
<th class="text-left">{{ _("Cheques Required") }}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th class="text-right">
|
||||
{{ frappe.utils.fmt_money(frappe.utils.flt(balance_row["outstanding"] - balance_row["future_amount"]), currency=balance_row["currency"]) }}</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
{% if(report.report_name == "Accounts Receivable" and filters.show_sales_person) %}
|
||||
<td>{{ data[i]["sales_person"] }}</td>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if(report.report_name == "Accounts Receivable" or report.report_name == "Accounts Payable") %}
|
||||
<th style="width: 10%">{{ _("Date") }}</th>
|
||||
<th style="width: 4%">{{ _("Age (Days)") }}</th>
|
||||
|
||||
{% if(report.report_name == "Accounts Receivable" and filters.show_sales_person) %}
|
||||
<th style="width: 14%">{{ _("Reference") }}</th>
|
||||
<th style="width: 10%">{{ _("Sales Person") }}</th>
|
||||
{% else %}
|
||||
<th style="width: 24%">{{ _("Reference") }}</th>
|
||||
{% endif %}
|
||||
{% if not(filters.show_future_payments) and filters.show_remarks %}
|
||||
<th style="width: 20%">
|
||||
{% if (filters.customer or filters.supplier or filters.customer_name) %}
|
||||
{{ _("Remarks") }}
|
||||
{% else %}
|
||||
{{ _("Party") }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endif %}
|
||||
<th style="width: 10%; text-align: right">{{ _("Invoiced Amount") }}</th>
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<th style="width: 10%; text-align: right">{{ _("Paid Amount") }}</th>
|
||||
<th style="width: 10%; text-align: right">
|
||||
{% if report.report_name == "Accounts Receivable" %}
|
||||
{{ _("Credit Note") }}
|
||||
{% else %}
|
||||
{{ _("Debit Note") }}
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endif %}
|
||||
<th style="width: 10%; text-align: right">{{ _("Outstanding Amount") }}</th>
|
||||
{% if(filters.show_future_payments) %}
|
||||
{% if(report.report_name == "Accounts Receivable") %}
|
||||
<th style="width: 12%">{{ _("Customer LPO No.") }}</th>
|
||||
{% endif %}
|
||||
<th style="width: 10%">{{ _("Future Payment Ref") }}</th>
|
||||
<th style="width: 10%">{{ _("Future Payment Amount") }}</th>
|
||||
<th style="width: 10%">{{ _("Remaining Balance") }}</th>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<th style="width: 40%">
|
||||
{% if (filters.customer or filters.supplier or filters.customer_name) %}
|
||||
{{ _("Remarks")}}
|
||||
{% else %}
|
||||
{{ _("Party") }}
|
||||
{% endif %}
|
||||
</th>
|
||||
<th style="width: 15%">{{ _("Total Invoiced Amount") }}</th>
|
||||
<th style="width: 15%">{{ _("Total Paid Amount") }}</th>
|
||||
<th style="width: 15%">
|
||||
{% if report.report_name == "Accounts Receivable Summary" %}
|
||||
{{ _("Credit Note Amount") }}
|
||||
{% else %}
|
||||
{{ _("Debit Note Amount") }}
|
||||
{% endif %}
|
||||
</th>
|
||||
<th style="width: 15%">{{ _("Total Outstanding Amount") }}</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in range(data|length) %}
|
||||
<tr>
|
||||
{% if(report.report_name == "Accounts Receivable" or report.report_name == "Accounts Payable") %}
|
||||
{% if(data[i]["party"]) %}
|
||||
<td>{{ frappe.format(data[i]["posting_date"], 'Date') }}</td>
|
||||
<td style="text-align: right">{{ data[i]["age"] }}</td>
|
||||
<td>
|
||||
{% if not(filters.show_future_payments) %}
|
||||
{{ data[i]["voucher_type"] }}
|
||||
<br>
|
||||
{% endif %}
|
||||
{{ data[i]["voucher_no"] }}
|
||||
</td>
|
||||
|
||||
{% if not (filters.show_future_payments) %}
|
||||
{% if(report.report_name == "Accounts Receivable" and filters.show_sales_person) %}
|
||||
<td>{{ data[i]["sales_person"] }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if not (filters.show_future_payments) and filters.show_remarks %}
|
||||
<td>
|
||||
{% if(not(filters.customer or filters.supplier or filters.customer_name)) %}
|
||||
{{ data[i]["party"] }}
|
||||
{% if(data[i]["customer_name"] and data[i]["customer_name"] != data[i]["party"]) %}
|
||||
<br> {{ data[i]["customer_name"] }}
|
||||
{% elif(data[i]["supplier_name"] != data[i]["party"]) %}
|
||||
<br> {{ data[i]["supplier_name"] }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if data[i]["remarks"] %}
|
||||
{{ _("Remarks") }}:
|
||||
{{ data[i]["remarks"] }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["invoiced"], currency=data[i]["currency"]) }}</td>
|
||||
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["paid"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["credit_note"], currency=data[i]["currency"]) }}</td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["outstanding"], currency=data[i]["currency"]) }}</td>
|
||||
|
||||
{% if(filters.show_future_payments) %}
|
||||
{% if(report.report_name == "Accounts Receivable") %}
|
||||
<td style="text-align: right">
|
||||
{{ data[i]["po_no"] }}</td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">{{ data[i]["future_ref"] }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["future_amount"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["remaining_balance"], currency=data[i]["currency"]) }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
{% if(report.report_name == "Accounts Receivable" and filters.show_sales_person) %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td></td>
|
||||
<td style="text-align: right"><b>{{ _("Total") }}</b></td>
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["invoiced"], data[i]["currency"]) }}</td>
|
||||
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["paid"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["credit_note"], currency=data[i]["currency"]) }} </td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["outstanding"], currency=data[i]["currency"]) }}</td>
|
||||
|
||||
{% if(filters.show_future_payments) %}
|
||||
{% if(report.report_name == "Accounts Receivable") %}
|
||||
<td style="text-align: right">
|
||||
{{ data[i]["po_no"] }}</td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">{{ data[i]["future_ref"] }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["future_amount"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["remaining_balance"], currency=data[i]["currency"]) }}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if(data[i]["party"] or " ") %}
|
||||
{% if not(data[i]["is_total_row"]) %}
|
||||
<td>
|
||||
{% if(not(filters.customer or filters.supplier or filters.customer_name)) %}
|
||||
{% if(not(filters.customer | filters.supplier)) %}
|
||||
{{ data[i]["party"] }}
|
||||
{% if(data[i]["customer_name"] and data[i]["customer_name"] != data[i]["party"]) %}
|
||||
<br> {{ data[i]["customer_name"] }}
|
||||
@@ -235,132 +311,73 @@
|
||||
<br> {{ data[i]["supplier_name"] }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if data[i]["remarks"] %}
|
||||
{{ _("Remarks") }}:
|
||||
{{ data[i]["remarks"] }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<br>{{ _("Remarks") }}:
|
||||
{{ data[i]["remarks"] }}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["invoiced"], currency=data[i]["currency"]) }}</td>
|
||||
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["paid"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["credit_note"], currency=data[i]["currency"]) }}</td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["outstanding"], currency=data[i]["currency"]) }}</td>
|
||||
|
||||
{% if(filters.show_future_payments) %}
|
||||
{% if(report.report_name == "Accounts Receivable") %}
|
||||
<td style="text-align: right">
|
||||
{{ data[i]["po_no"] }}</td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">{{ data[i]["future_ref"] }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["future_amount"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["remaining_balance"], currency=data[i]["currency"]) }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td></td>
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
{% if(report.report_name == "Accounts Receivable" and filters.show_sales_person) %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
<td></td>
|
||||
<td style="text-align: right"><b>{{ _("Total") }}</b></td>
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["invoiced"], data[i]["currency"]) }}</td>
|
||||
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["paid"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["credit_note"], currency=data[i]["currency"]) }} </td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">
|
||||
{{ frappe.utils.fmt_money(data[i]["outstanding"], currency=data[i]["currency"]) }}</td>
|
||||
|
||||
{% if(filters.show_future_payments) %}
|
||||
{% if(report.report_name == "Accounts Receivable") %}
|
||||
<td style="text-align: right">
|
||||
{{ data[i]["po_no"] }}</td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">{{ data[i]["future_ref"] }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["future_amount"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["remaining_balance"], currency=data[i]["currency"]) }}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if(data[i]["party"] or " ") %}
|
||||
{% if not(data[i]["is_total_row"]) %}
|
||||
<td>
|
||||
{% if(not(filters.customer | filters.supplier)) %}
|
||||
{{ data[i]["party"] }}
|
||||
{% if(data[i]["customer_name"] and data[i]["customer_name"] != data[i]["party"]) %}
|
||||
<br> {{ data[i]["customer_name"] }}
|
||||
{% elif(data[i]["supplier_name"] != data[i]["party"]) %}
|
||||
<br> {{ data[i]["supplier_name"] }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<br>{{ _("Remarks") }}:
|
||||
{{ data[i]["remarks"] }}
|
||||
</td>
|
||||
{% else %}
|
||||
<td><b>{{ _("Total") }}</b></td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["invoiced"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["paid"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["credit_note"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["outstanding"], currency=data[i]["currency"]) }}</td>
|
||||
<td><b>{{ _("Total") }}</b></td>
|
||||
{% endif %}
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["invoiced"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["paid"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["credit_note"], currency=data[i]["currency"]) }}</td>
|
||||
<td style="text-align: right">{{ frappe.utils.fmt_money(data[i]["outstanding"], currency=data[i]["currency"]) }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<td></td>
|
||||
<td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<td></td>
|
||||
<td></td>
|
||||
{% if (filters.show_future_payments) or filters.show_remarks %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
{% if not(filters.show_future_payments) %}
|
||||
<td></td>
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="invoiced"), currency=data[0]["currency"]) }}</b></td>
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="paid"), currency=data[0]["currency"]) }}</b></td>
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="credit_note"), currency=data[0]["currency"]) }}</b></td>
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="outstanding"), currency=data[0]["currency"]) }}</b></td>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
{% if ageing %}
|
||||
<h4 class="text-center">{{ _("Ageing Report based on ") }} {{ ageing.ageing_based_on }}
|
||||
{{ _("up to " ) }} {{ frappe.format(filters.report_date, 'Date')}}
|
||||
</h4>
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 25%">0 - 30 Days</th>
|
||||
<th style="width: 25%">30 - 60 Days</th>
|
||||
<th style="width: 25%">60 - 90 Days</th>
|
||||
<th style="width: 25%">90 - 120 Days</th>
|
||||
<th style="width: 20%">Above 120 Days</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range1, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range2, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range3, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range4, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range5, currency=filters.presentation_currency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% if terms_and_conditions %}
|
||||
<div>
|
||||
{{ terms_and_conditions }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-right text-muted">{{ _("Printed On ") }}{{ frappe.utils.now() }}</p>
|
||||
{% else %}
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="invoiced"), currency=data[0]["currency"]) }}</b></td>
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="outstanding"), currency=data[0]["currency"]) }}</b></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="future_amount"), currency=data[0]["currency"]) }}</b></td>
|
||||
<td style="text-align: right"><b>{{ frappe.utils.fmt_money(data|sum(attribute="remaining_balance"), currency=data[0]["currency"]) }}</b></td>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
{% if ageing %}
|
||||
<h4 class="text-center">
|
||||
{{ _("Ageing Report based on {0} up to {1}").format(
|
||||
ageing.ageing_based_on,
|
||||
frappe.format(filters.report_date, "Date")
|
||||
) }}
|
||||
</h4>
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 25%">{{ _("0 - 30 Days") }}</th>
|
||||
<th style="width: 25%">{{ _("30 - 60 Days") }}</th>
|
||||
<th style="width: 25%">{{ _("60 - 90 Days") }}</th>
|
||||
<th style="width: 25%">{{ _("90 - 120 Days") }}</th>
|
||||
<th style="width: 20%">{{ _("Above 120 Days") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range1, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range2, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range3, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range4, currency=data[0]["currency"]) }}</td>
|
||||
<td>{{ frappe.utils.fmt_money(ageing.range5, currency=filters.presentation_currency) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% if terms_and_conditions %}
|
||||
<div>
|
||||
{{ terms_and_conditions }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-right text-muted">{{ _("Printed on {0}").format(frappe.utils.now()) }}</p>
|
||||
|
||||
@@ -126,8 +126,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
|
||||
cur_frm.add_custom_button(
|
||||
if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
|
||||
this.frm.add_custom_button(
|
||||
__("Payment Request"),
|
||||
function () {
|
||||
me.make_payment_request();
|
||||
@@ -575,17 +575,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"]],
|
||||
|
||||
@@ -115,6 +115,10 @@ class RepostAccountingLedger(Document):
|
||||
def generate_preview(self):
|
||||
from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns
|
||||
|
||||
if not self.vouchers:
|
||||
frappe.msgprint(_("Add vouchers to generate preview."))
|
||||
return
|
||||
|
||||
gl_columns = []
|
||||
gl_data = []
|
||||
|
||||
@@ -142,6 +146,7 @@ class RepostAccountingLedger(Document):
|
||||
account_repost_doc=self.name,
|
||||
is_async=True,
|
||||
job_name=job_name,
|
||||
enqueue_after_commit=True,
|
||||
)
|
||||
frappe.msgprint(_("Repost has started in the background"))
|
||||
else:
|
||||
@@ -210,16 +215,22 @@ def start_repost(account_repost_doc=str) -> None:
|
||||
|
||||
|
||||
def get_allowed_types_from_settings(child_doc: bool = False):
|
||||
repost_docs = [
|
||||
x.document_type
|
||||
for x in frappe.db.get_all(
|
||||
"Repost Allowed Types", filters={"allowed": True}, fields=["distinct(document_type)"]
|
||||
)
|
||||
]
|
||||
# Avoid DISTINCT(...) here: Frappe applies a default ORDER BY which breaks on Postgres
|
||||
# when used with SELECT DISTINCT.
|
||||
repost_docs = frappe.db.get_all(
|
||||
"Repost Allowed Types",
|
||||
filters={"allowed": True},
|
||||
pluck="document_type",
|
||||
)
|
||||
|
||||
# De-dupe while preserving order (first occurrence wins)
|
||||
repost_docs = list(dict.fromkeys(repost_docs))
|
||||
result = repost_docs
|
||||
|
||||
if repost_docs and child_doc:
|
||||
result.extend(get_child_docs(repost_docs))
|
||||
# Keep uniqueness after extending
|
||||
result = list(dict.fromkeys(result))
|
||||
|
||||
return result
|
||||
|
||||
@@ -286,8 +297,11 @@ def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters
|
||||
if txt:
|
||||
filters.update({"document_type": ("like", f"%{txt}%")})
|
||||
|
||||
if allowed_types := frappe.db.get_all(
|
||||
"Repost Allowed Types", filters=filters, fields=["distinct(document_type)"], as_list=1
|
||||
):
|
||||
return allowed_types
|
||||
return []
|
||||
allowed_types = frappe.db.get_all(
|
||||
"Repost Allowed Types",
|
||||
filters=filters,
|
||||
pluck="document_type",
|
||||
)
|
||||
|
||||
allowed_types = list(dict.fromkeys(allowed_types))
|
||||
return [[dt] for dt in allowed_types]
|
||||
|
||||
@@ -117,12 +117,20 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
return item.delivery_note ? true : false;
|
||||
});
|
||||
|
||||
if (!from_delivery_note && !is_delivered_by_supplier) {
|
||||
cur_frm.add_custom_button(
|
||||
__("Delivery"),
|
||||
cur_frm.cscript["Make Delivery Note"],
|
||||
__("Create")
|
||||
if (!is_delivered_by_supplier) {
|
||||
const should_create_delivery_note = doc.items.some(
|
||||
(item) =>
|
||||
item.qty - item.delivered_qty > 0 &&
|
||||
!item.dn_detail &&
|
||||
!item.delivered_by_supplier
|
||||
);
|
||||
if (should_create_delivery_note) {
|
||||
this.frm.add_custom_button(
|
||||
__("Delivery Note"),
|
||||
this.frm.cscript["Make Delivery Note"],
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,10 +656,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");
|
||||
};
|
||||
|
||||
cur_frm.set_query("debit_to", function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -362,21 +362,34 @@ class SalesInvoice(SellingController):
|
||||
validate_docs_for_deferred_accounting([self.name], [])
|
||||
|
||||
def validate_fixed_asset(self):
|
||||
for d in self.get("items"):
|
||||
if d.is_fixed_asset and d.meta.get_field("asset") and d.asset:
|
||||
asset = frappe.get_doc("Asset", d.asset)
|
||||
if self.doctype == "Sales Invoice" and self.docstatus == 1:
|
||||
if self.update_stock:
|
||||
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
|
||||
if self.doctype != "Sales Invoice":
|
||||
return
|
||||
|
||||
elif asset.status in ("Scrapped", "Cancelled", "Capitalized") or (
|
||||
asset.status == "Sold" and not self.is_return
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Asset {1} cannot be submitted, it is already {2}").format(
|
||||
d.idx, d.asset, asset.status
|
||||
for d in self.get("items"):
|
||||
if d.is_fixed_asset:
|
||||
if d.asset:
|
||||
if not self.is_return:
|
||||
asset_status = frappe.db.get_value("Asset", d.asset, "status")
|
||||
if self.update_stock:
|
||||
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
|
||||
|
||||
elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
|
||||
d.idx, d.asset, asset_status
|
||||
)
|
||||
)
|
||||
elif asset_status == "Sold" and not self.is_return:
|
||||
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
|
||||
elif not self.return_against:
|
||||
frappe.throw(
|
||||
_("Row #{0}: Return Against is required for returning asset").format(d.idx)
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
|
||||
title=_("Missing Asset"),
|
||||
)
|
||||
|
||||
def validate_item_cost_centers(self):
|
||||
for item in self.items:
|
||||
@@ -465,6 +478,8 @@ class SalesInvoice(SellingController):
|
||||
self.update_stock_reservation_entries()
|
||||
self.update_stock_ledger()
|
||||
|
||||
self.process_asset_depreciation()
|
||||
|
||||
# this sequence because outstanding may get -ve
|
||||
self.make_gl_entries()
|
||||
|
||||
@@ -561,6 +576,8 @@ class SalesInvoice(SellingController):
|
||||
if self.update_stock == 1:
|
||||
self.update_stock_ledger()
|
||||
|
||||
self.process_asset_depreciation()
|
||||
|
||||
self.make_gl_entries_on_cancel()
|
||||
|
||||
if self.update_stock == 1:
|
||||
@@ -1182,6 +1199,91 @@ class SalesInvoice(SellingController):
|
||||
):
|
||||
throw(_("Delivery Note {0} is not submitted").format(d.delivery_note))
|
||||
|
||||
def process_asset_depreciation(self):
|
||||
if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
|
||||
self.depreciate_asset_on_sale()
|
||||
else:
|
||||
self.restore_asset()
|
||||
|
||||
self.update_asset()
|
||||
|
||||
def depreciate_asset_on_sale(self):
|
||||
"""
|
||||
Depreciate asset on sale or cancellation of return sales invoice
|
||||
"""
|
||||
disposal_date = self.get_disposal_date()
|
||||
for d in self.get("items"):
|
||||
if d.asset:
|
||||
asset = frappe.get_doc("Asset", d.asset)
|
||||
if asset.calculate_depreciation and asset.status != "Fully Depreciated":
|
||||
depreciate_asset(asset, disposal_date, self.get_note_for_asset_sale(asset))
|
||||
|
||||
def get_note_for_asset_sale(self, asset):
|
||||
return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format(
|
||||
get_link_to_form(asset.doctype, asset.name),
|
||||
_("returned") if self.is_return else _("sold"),
|
||||
get_link_to_form(self.doctype, self.get("name")),
|
||||
)
|
||||
|
||||
def restore_asset(self):
|
||||
"""
|
||||
Restore asset on return or cancellation of original sales invoice
|
||||
"""
|
||||
|
||||
for d in self.get("items"):
|
||||
if d.asset:
|
||||
asset = frappe.get_cached_doc("Asset", d.asset)
|
||||
if asset.calculate_depreciation:
|
||||
posting_date = self.get_disposal_date()
|
||||
reverse_depreciation_entry_made_after_disposal(asset, posting_date)
|
||||
|
||||
note = self.get_note_for_asset_return(asset)
|
||||
reset_depreciation_schedule(asset, self.posting_date, note)
|
||||
|
||||
def get_note_for_asset_return(self, asset):
|
||||
asset_link = get_link_to_form(asset.doctype, asset.name)
|
||||
invoice_link = get_link_to_form(self.doctype, self.get("name"))
|
||||
if self.is_return:
|
||||
return _(
|
||||
"This schedule was created when Asset {0} was returned through Sales Invoice {1}."
|
||||
).format(asset_link, invoice_link)
|
||||
else:
|
||||
return _(
|
||||
"This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation."
|
||||
).format(asset_link, invoice_link)
|
||||
|
||||
def update_asset(self):
|
||||
"""
|
||||
Update asset status, disposal date and asset activity on sale or return sales invoice
|
||||
"""
|
||||
|
||||
def _update_asset(asset, disposal_date, note, asset_status=None):
|
||||
frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date)
|
||||
add_asset_activity(asset.name, note)
|
||||
asset.set_status(asset_status)
|
||||
|
||||
disposal_date = self.get_disposal_date()
|
||||
for d in self.get("items"):
|
||||
if d.asset:
|
||||
asset = frappe.get_cached_doc("Asset", d.asset)
|
||||
|
||||
if (self.is_return and self.docstatus == 1) or (not self.is_return and self.docstatus == 2):
|
||||
note = _("Asset returned") if self.is_return else _("Asset sold")
|
||||
asset_status, disposal_date = None, None
|
||||
else:
|
||||
note = _("Asset sold") if not self.is_return else _("Return invoice of asset cancelled")
|
||||
asset_status = "Sold"
|
||||
|
||||
_update_asset(asset, disposal_date, note, asset_status)
|
||||
|
||||
def get_disposal_date(self):
|
||||
if self.is_return:
|
||||
disposal_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
|
||||
else:
|
||||
disposal_date = self.posting_date
|
||||
|
||||
return disposal_date
|
||||
|
||||
def make_gl_entries(self, gl_entries=None, from_repost=False):
|
||||
from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries
|
||||
|
||||
@@ -1349,73 +1451,17 @@ class SalesInvoice(SellingController):
|
||||
)
|
||||
|
||||
for item in self.get("items"):
|
||||
if flt(item.base_net_amount, item.precision("base_net_amount")) or item.is_fixed_asset:
|
||||
if (
|
||||
flt(item.base_net_amount, item.precision("base_net_amount"))
|
||||
or item.is_fixed_asset
|
||||
or enable_discount_accounting
|
||||
):
|
||||
# Do not book income for transfer within same company
|
||||
if self.is_internal_transfer():
|
||||
continue
|
||||
|
||||
if item.is_fixed_asset:
|
||||
asset = self.get_asset(item)
|
||||
|
||||
if (self.docstatus == 2 and not self.is_return) or (
|
||||
self.docstatus == 1 and self.is_return
|
||||
):
|
||||
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
|
||||
asset,
|
||||
item.base_net_amount,
|
||||
item.finance_book,
|
||||
self.get("doctype"),
|
||||
self.get("name"),
|
||||
self.get("posting_date"),
|
||||
)
|
||||
asset.db_set("disposal_date", None)
|
||||
add_asset_activity(asset.name, _("Asset returned"))
|
||||
asset_status = asset.get_status()
|
||||
|
||||
if asset.calculate_depreciation and not asset_status == "Fully Depreciated":
|
||||
posting_date = (
|
||||
frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
|
||||
if self.is_return
|
||||
else self.posting_date
|
||||
)
|
||||
reverse_depreciation_entry_made_after_disposal(asset, posting_date)
|
||||
notes = _(
|
||||
"This schedule was created when Asset {0} was returned through Sales Invoice {1}."
|
||||
).format(
|
||||
get_link_to_form(asset.doctype, asset.name),
|
||||
get_link_to_form(self.doctype, self.get("name")),
|
||||
)
|
||||
reset_depreciation_schedule(asset, self.posting_date, notes)
|
||||
asset.reload()
|
||||
|
||||
else:
|
||||
if asset.calculate_depreciation:
|
||||
if not asset.status == "Fully Depreciated":
|
||||
notes = _(
|
||||
"This schedule was created when Asset {0} was sold through Sales Invoice {1}."
|
||||
).format(
|
||||
get_link_to_form(asset.doctype, asset.name),
|
||||
get_link_to_form(self.doctype, self.get("name")),
|
||||
)
|
||||
depreciate_asset(asset, self.posting_date, notes)
|
||||
asset.reload()
|
||||
|
||||
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
|
||||
asset,
|
||||
item.base_net_amount,
|
||||
item.finance_book,
|
||||
self.get("doctype"),
|
||||
self.get("name"),
|
||||
self.get("posting_date"),
|
||||
)
|
||||
asset.db_set("disposal_date", self.posting_date)
|
||||
add_asset_activity(asset.name, _("Asset sold"))
|
||||
|
||||
for gle in fixed_asset_gl_entries:
|
||||
gle["against"] = self.customer
|
||||
gl_entries.append(self.get_gl_dict(gle, item=item))
|
||||
|
||||
self.set_asset_status(asset)
|
||||
if item.is_fixed_asset and item.asset:
|
||||
self.get_gl_entries_for_fixed_asset(item, gl_entries)
|
||||
|
||||
else:
|
||||
income_account = (
|
||||
@@ -1451,17 +1497,31 @@ class SalesInvoice(SellingController):
|
||||
if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company):
|
||||
gl_entries += super().get_gl_entries()
|
||||
|
||||
def get_asset(self, item):
|
||||
if item.get("asset"):
|
||||
asset = frappe.get_doc("Asset", item.asset)
|
||||
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
|
||||
asset = frappe.get_cached_doc("Asset", item.asset)
|
||||
|
||||
if self.is_return:
|
||||
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
|
||||
asset,
|
||||
item.base_net_amount,
|
||||
item.finance_book,
|
||||
self.get("doctype"),
|
||||
self.get("name"),
|
||||
self.get("posting_date"),
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Row #{0}: You must select an Asset for Item {1}.").format(item.idx, item.item_name),
|
||||
title=_("Missing Asset"),
|
||||
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
|
||||
asset,
|
||||
item.base_net_amount,
|
||||
item.finance_book,
|
||||
self.get("doctype"),
|
||||
self.get("name"),
|
||||
self.get("posting_date"),
|
||||
)
|
||||
|
||||
self.check_finance_books(item, asset)
|
||||
return asset
|
||||
for gle in fixed_asset_gl_entries:
|
||||
gle["against"] = self.customer
|
||||
gl_entries.append(self.get_gl_dict(gle, item=item))
|
||||
|
||||
@property
|
||||
def enable_discount_accounting(self):
|
||||
@@ -2151,7 +2211,9 @@ def make_delivery_note(source_name, target_doc=None):
|
||||
"cost_center": "cost_center",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.delivered_by_supplier != 1,
|
||||
"condition": lambda doc: doc.delivered_by_supplier != 1
|
||||
and not doc.dn_detail
|
||||
and doc.qty - doc.delivered_qty > 0,
|
||||
},
|
||||
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
|
||||
"Sales Team": {
|
||||
|
||||
@@ -2974,6 +2974,60 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 10 - _TC")
|
||||
self.assertEqual(sales_invoice.items[0].item_tax_rate, item_tax_map)
|
||||
|
||||
def test_item_tax_template_change_with_grand_total_discount(self):
|
||||
"""
|
||||
Test that when item tax template changes due to discount on Grand Total,
|
||||
the tax calculations are consistent.
|
||||
"""
|
||||
item = create_item("Test Item With Multiple Tax Templates")
|
||||
|
||||
item.set("taxes", [])
|
||||
item.append(
|
||||
"taxes",
|
||||
{
|
||||
"item_tax_template": "_Test Account Excise Duty @ 10 - _TC",
|
||||
"minimum_net_rate": 0,
|
||||
"maximum_net_rate": 500,
|
||||
},
|
||||
)
|
||||
|
||||
item.append(
|
||||
"taxes",
|
||||
{
|
||||
"item_tax_template": "_Test Account Excise Duty @ 12 - _TC",
|
||||
"minimum_net_rate": 501,
|
||||
"maximum_net_rate": 1000,
|
||||
},
|
||||
)
|
||||
|
||||
item.save()
|
||||
|
||||
si = create_sales_invoice(item=item.name, rate=700, do_not_save=True)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "_Test Account Excise Duty - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"description": "Excise Duty",
|
||||
"rate": 0,
|
||||
},
|
||||
)
|
||||
si.insert()
|
||||
|
||||
self.assertEqual(si.items[0].item_tax_template, "_Test Account Excise Duty @ 12 - _TC")
|
||||
|
||||
si.apply_discount_on = "Grand Total"
|
||||
si.discount_amount = 300
|
||||
si.save()
|
||||
|
||||
# Verify template changed to 10%
|
||||
self.assertEqual(si.items[0].item_tax_template, "_Test Account Excise Duty @ 10 - _TC")
|
||||
self.assertEqual(si.taxes[0].tax_amount, 70) # 10% of 700
|
||||
self.assertEqual(si.grand_total, 470) # 700 + 70 - 300
|
||||
|
||||
si.submit()
|
||||
|
||||
@change_settings("Selling Settings", {"enable_discount_accounting": 1})
|
||||
def test_sales_invoice_with_discount_accounting_enabled(self):
|
||||
discount_account = create_account(
|
||||
@@ -4721,6 +4775,66 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
|
||||
self.assertEqual(q[0][0], 1)
|
||||
|
||||
@change_settings("Selling Settings", {"set_zero_rate_for_expired_batch": True})
|
||||
def test_zero_valuation_for_standalone_credit_note_with_expired_batch(self):
|
||||
item_code = "_Test Item for Expiry Batch Zero Valuation"
|
||||
make_item_for_si(
|
||||
item_code,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"has_expiry_date": 1,
|
||||
"shelf_life_in_days": 2,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBATCH-EBZV.####",
|
||||
},
|
||||
)
|
||||
|
||||
se = make_stock_entry(
|
||||
item_code=item_code,
|
||||
qty=10,
|
||||
target="_Test Warehouse - _TC",
|
||||
rate=100,
|
||||
)
|
||||
|
||||
# fetch batch no from bundle
|
||||
batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
|
||||
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(nowdate(), 3),
|
||||
item=item_code,
|
||||
qty=-10,
|
||||
rate=100,
|
||||
is_return=1,
|
||||
update_stock=1,
|
||||
use_serial_batch_fields=1,
|
||||
do_not_save=1,
|
||||
do_not_submit=1,
|
||||
)
|
||||
|
||||
si.items[0].batch_no = batch_no
|
||||
si.save()
|
||||
si.submit()
|
||||
|
||||
si.reload()
|
||||
# check zero incoming rate in voucher
|
||||
self.assertEqual(si.items[0].incoming_rate, 0.0)
|
||||
|
||||
# chekc zero incoming rate in stock ledger
|
||||
stock_ledger_entry = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Sales Invoice",
|
||||
"voucher_no": si.name,
|
||||
"item_code": item_code,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
["incoming_rate", "valuation_rate"],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
self.assertEqual(stock_ledger_entry.incoming_rate, 0.0)
|
||||
|
||||
|
||||
def make_item_for_si(item_code, properties=None):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "rate",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
@@ -102,7 +102,7 @@
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
@@ -199,7 +199,7 @@
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
@@ -221,7 +221,7 @@
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 0,
|
||||
"allow_on_submit": 0,
|
||||
@@ -324,7 +324,7 @@
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2018-01-10 18:32:36.201124",
|
||||
"modified": "2025-12-10 08:06:40.611761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Balance",
|
||||
@@ -339,4 +339,4 @@
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1,
|
||||
"track_seen": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -848,6 +848,90 @@ class TestTaxWithholdingCategory(FrappeTestCase):
|
||||
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(
|
||||
@@ -996,6 +1080,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 [
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
get_checks_for_pl_and_bs_accounts,
|
||||
)
|
||||
from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import (
|
||||
get_dimension_filter_map,
|
||||
@@ -199,19 +200,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)
|
||||
@@ -289,7 +291,9 @@ def merge_similar_entries(gl_map, precision=None):
|
||||
company_currency = erpnext.get_company_currency(company)
|
||||
|
||||
if not precision:
|
||||
precision = get_field_precision(frappe.get_meta("GL Entry").get_field("debit"), company_currency)
|
||||
precision = get_field_precision(
|
||||
frappe.get_meta("GL Entry").get_field("debit"), currency=company_currency
|
||||
)
|
||||
|
||||
# filter zero debit and credit entries
|
||||
merged_gl_map = filter(
|
||||
@@ -412,7 +416,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)
|
||||
|
||||
|
||||
@@ -605,6 +613,18 @@ def update_accounting_dimensions(round_off_gle):
|
||||
|
||||
for dimension in dimensions:
|
||||
round_off_gle[dimension] = dimension_values.get(dimension)
|
||||
else:
|
||||
report_type = frappe.get_cached_value("Account", round_off_gle.account, "report_type")
|
||||
for dimension in get_checks_for_pl_and_bs_accounts():
|
||||
if (
|
||||
round_off_gle.company == dimension.company
|
||||
and (
|
||||
(report_type == "Profit and Loss" and dimension.mandatory_for_pl)
|
||||
or (report_type == "Balance Sheet" and dimension.mandatory_for_bs)
|
||||
)
|
||||
and dimension.default_dimension
|
||||
):
|
||||
round_off_gle[dimension.fieldname] = dimension.default_dimension
|
||||
|
||||
|
||||
def get_round_off_account_and_cost_center(company, voucher_type, voucher_no, use_company_default=False):
|
||||
|
||||
@@ -1056,3 +1056,21 @@ def add_party_account(party_type, party, company, account):
|
||||
|
||||
def render_address(address, check_permissions=True):
|
||||
return frappe.call(_render_address, address, check_permissions=check_permissions)
|
||||
|
||||
|
||||
def validate_party_currency_before_merging(party_type, old_party, new_party):
|
||||
for company in frappe.get_all("Company"):
|
||||
old_party_currency = get_party_gle_currency(party_type, old_party, company.name)
|
||||
new_party_currency = get_party_gle_currency(party_type, new_party, company.name)
|
||||
|
||||
if old_party_currency and new_party_currency and old_party_currency != new_party_currency:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot merge {0} '{1}' into '{2}' as both have existing accounting entries in different currencies for company '{3}'."
|
||||
).format(
|
||||
party_type,
|
||||
old_party,
|
||||
new_party,
|
||||
company.name,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -165,6 +165,10 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
var filters = report.get_values();
|
||||
frappe.set_route("query-report", "Accounts Payable Summary", { company: filters.company });
|
||||
});
|
||||
|
||||
if (frappe.boot.sysdefaults.default_ageing_range) {
|
||||
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -102,6 +102,11 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
label: __("Revaluation Journals"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
fieldname: "show_gl_balance",
|
||||
label: __("Show GL Balance"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
],
|
||||
|
||||
onload: function (report) {
|
||||
@@ -109,6 +114,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
var filters = report.get_values();
|
||||
frappe.set_route("query-report", "Accounts Payable", { company: filters.company });
|
||||
});
|
||||
|
||||
if (frappe.boot.sysdefaults.default_ageing_range) {
|
||||
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -192,6 +192,10 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
var filters = report.get_values();
|
||||
frappe.set_route("query-report", "Accounts Receivable Summary", { company: filters.company });
|
||||
});
|
||||
|
||||
if (frappe.boot.sysdefaults.default_ageing_range) {
|
||||
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -137,6 +137,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
var filters = report.get_values();
|
||||
frappe.set_route("query-report", "Accounts Receivable", { company: filters.company });
|
||||
});
|
||||
|
||||
if (frappe.boot.sysdefaults.default_ageing_range) {
|
||||
report.set_filter_value("range", frappe.boot.sysdefaults.default_ageing_range);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
)
|
||||
|
||||
if self.filters.show_gl_balance:
|
||||
gl_balance_map = get_gl_balance(self.filters.report_date, self.filters.company)
|
||||
gl_balance_map = get_gl_balance(self.filters.report_date, self.filters.company, self.account_type)
|
||||
|
||||
for party, party_dict in self.party_total.items():
|
||||
if flt(party_dict.outstanding, self.currency_precision) == 0:
|
||||
@@ -206,11 +206,15 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
)
|
||||
|
||||
|
||||
def get_gl_balance(report_date, company):
|
||||
def get_gl_balance(report_date, company, account_type):
|
||||
if account_type == "Payable":
|
||||
balance_calc_fields = ["party", "SUM(credit - debit) AS balance"]
|
||||
else:
|
||||
balance_calc_fields = ["party", "SUM(debit - credit) AS balance"]
|
||||
return frappe._dict(
|
||||
frappe.db.get_all(
|
||||
"GL Entry",
|
||||
fields=["party", "sum(debit - credit)"],
|
||||
fields=balance_calc_fields,
|
||||
filters={"posting_date": ("<=", report_date), "is_cancelled": 0, "company": company},
|
||||
group_by="party",
|
||||
as_list=1,
|
||||
|
||||
@@ -482,7 +482,7 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
|
||||
immutable_ledger = frappe.db.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
|
||||
|
||||
@@ -493,10 +493,14 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
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
|
||||
@@ -526,11 +530,11 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
|
||||
|
||||
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:
|
||||
|
||||
@@ -219,13 +219,18 @@ def get_net_profit(
|
||||
|
||||
has_value = False
|
||||
|
||||
gross_income_roots = [row for row in (gross_income or []) if not flt(row.get("indent"))]
|
||||
non_gross_income_roots = [row for row in (non_gross_income or []) if not flt(row.get("indent"))]
|
||||
gross_expense_roots = [row for row in (gross_expense or []) if not flt(row.get("indent"))]
|
||||
non_gross_expense_roots = [row for row in (non_gross_expense or []) if not flt(row.get("indent"))]
|
||||
|
||||
for period in period_list:
|
||||
key = period if consolidated else period.key
|
||||
gross_income_for_period = flt(gross_income[0].get(key, 0)) if gross_income else 0
|
||||
non_gross_income_for_period = flt(non_gross_income[0].get(key, 0)) if non_gross_income else 0
|
||||
|
||||
gross_expense_for_period = flt(gross_expense[0].get(key, 0)) if gross_expense else 0
|
||||
non_gross_expense_for_period = flt(non_gross_expense[0].get(key, 0)) if non_gross_expense else 0
|
||||
gross_income_for_period = sum(flt(row.get(key, 0)) for row in gross_income_roots)
|
||||
non_gross_income_for_period = sum(flt(row.get(key, 0)) for row in non_gross_income_roots)
|
||||
gross_expense_for_period = sum(flt(row.get(key, 0)) for row in gross_expense_roots)
|
||||
non_gross_expense_for_period = sum(flt(row.get(key, 0)) for row in non_gross_expense_roots)
|
||||
|
||||
total_income = gross_income_for_period + non_gross_income_for_period
|
||||
total_expense = gross_expense_for_period + non_gross_expense_for_period
|
||||
|
||||
@@ -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 (
|
||||
@@ -16,7 +15,7 @@ from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register i
|
||||
get_group_by_and_display_fields,
|
||||
get_tax_accounts,
|
||||
)
|
||||
from erpnext.accounts.report.utils import get_query_columns, get_values_for_columns
|
||||
from erpnext.accounts.report.utils import get_values_for_columns
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@@ -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):
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
{
|
||||
scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0),
|
||||
scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0),
|
||||
f"{tax}_rate": item_tax.get("tax_rate", 0),
|
||||
f"{tax}_amount": item_tax.get("tax_amount", 0),
|
||||
}
|
||||
)
|
||||
total_tax += flt(item_tax.get("tax_amount"))
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.meta import get_field_precision
|
||||
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,16 +32,6 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
if item_list:
|
||||
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)
|
||||
|
||||
@@ -102,8 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
{
|
||||
scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0),
|
||||
scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0),
|
||||
f"{tax}_rate": item_tax.get("tax_rate", 0),
|
||||
f"{tax}_amount": item_tax.get("tax_amount", 0),
|
||||
}
|
||||
)
|
||||
if item_tax.get("is_other_charges"):
|
||||
@@ -115,7 +105,7 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
{
|
||||
"total_tax": total_tax,
|
||||
"total_other_charges": total_other_charges,
|
||||
"total": d.base_net_amount + total_tax,
|
||||
"total": d.base_net_amount + total_tax + total_other_charges,
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
@@ -546,9 +536,10 @@ def get_tax_accounts(
|
||||
import json
|
||||
|
||||
item_row_map = {}
|
||||
tax_columns = []
|
||||
tax_columns = {}
|
||||
invoice_item_row = {}
|
||||
itemised_tax = {}
|
||||
scrubbed_description_map = {}
|
||||
add_deduct_tax = "charge_type"
|
||||
|
||||
tax_amount_precision = (
|
||||
@@ -605,9 +596,14 @@ def get_tax_accounts(
|
||||
tax_amount,
|
||||
) in tax_details:
|
||||
description = handle_html(description)
|
||||
if description not in tax_columns and tax_amount:
|
||||
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 tax_amount:
|
||||
# as description is text editor earlier and markup can break the column convention in reports
|
||||
tax_columns.append(description)
|
||||
tax_columns[scrubbed_description] = description
|
||||
|
||||
if item_wise_tax_detail:
|
||||
try:
|
||||
@@ -641,7 +637,7 @@ def get_tax_accounts(
|
||||
else tax_value
|
||||
)
|
||||
|
||||
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
|
||||
itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict(
|
||||
{
|
||||
"tax_rate": tax_rate,
|
||||
"tax_amount": tax_value,
|
||||
@@ -653,7 +649,7 @@ def get_tax_accounts(
|
||||
continue
|
||||
elif charge_type == "Actual" and tax_amount:
|
||||
for d in invoice_item_row.get(parent, []):
|
||||
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
|
||||
itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict(
|
||||
{
|
||||
"tax_rate": "NA",
|
||||
"tax_amount": flt(
|
||||
@@ -662,12 +658,14 @@ def get_tax_accounts(
|
||||
}
|
||||
)
|
||||
|
||||
tax_columns.sort()
|
||||
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,
|
||||
}
|
||||
@@ -676,7 +674,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,
|
||||
@@ -714,7 +712,7 @@ def get_tax_accounts(
|
||||
},
|
||||
]
|
||||
|
||||
return itemised_tax, tax_columns
|
||||
return itemised_tax, tax_columns_list
|
||||
|
||||
|
||||
def add_total_row(
|
||||
@@ -807,5 +805,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"])
|
||||
|
||||
@@ -399,7 +399,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
|
||||
|
||||
@@ -81,5 +81,11 @@ frappe.query_reports["Trial Balance for Party"] = {
|
||||
label: __("Show zero values"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
{
|
||||
fieldname: "exclude_zero_balance_parties",
|
||||
label: __("Exclude Zero Balance Parties"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -75,20 +75,20 @@ def get_data(filters, show_party_name):
|
||||
closing_debit, closing_credit = toggle_debit_credit(opening_debit + debit, opening_credit + credit)
|
||||
row.update({"closing_debit": closing_debit, "closing_credit": closing_credit})
|
||||
|
||||
# totals
|
||||
for col in total_row:
|
||||
total_row[col] += row.get(col)
|
||||
|
||||
row.update({"currency": company_currency})
|
||||
|
||||
has_value = False
|
||||
if opening_debit or opening_credit or debit or credit or closing_debit or closing_credit:
|
||||
has_value = True
|
||||
# Exclude zero balance parties if filter is set
|
||||
if filters.get("exclude_zero_balance_parties") and not closing_debit and not closing_credit:
|
||||
continue
|
||||
|
||||
if cint(filters.show_zero_values) or has_value:
|
||||
data.append(row)
|
||||
|
||||
# Add total row
|
||||
# totals
|
||||
for col in total_row:
|
||||
total_row[col] += row.get(col)
|
||||
|
||||
total_row.update({"party": "'" + _("Totals") + "'", "currency": company_currency})
|
||||
data.append(total_row)
|
||||
|
||||
@@ -511,6 +511,7 @@ def reconcile_against_document(
|
||||
doc.make_advance_gl_entries(entry=row)
|
||||
else:
|
||||
_delete_pl_entries(voucher_type, voucher_no)
|
||||
_delete_adv_pl_entries(voucher_type, voucher_no)
|
||||
gl_map = doc.build_gl_map()
|
||||
# Make sure there is no overallocation
|
||||
from erpnext.accounts.general_ledger import process_debit_credit_difference
|
||||
@@ -1867,6 +1868,7 @@ def get_payment_ledger_entries(gl_entries, cancel=0):
|
||||
account=gle.account,
|
||||
party_type=gle.party_type,
|
||||
party=gle.party,
|
||||
project=gle.project,
|
||||
cost_center=gle.cost_center,
|
||||
finance_book=gle.finance_book,
|
||||
due_date=gle.due_date,
|
||||
|
||||
@@ -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);
|
||||
@@ -200,9 +206,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");
|
||||
@@ -228,26 +235,64 @@ frappe.ui.form.on("Asset", {
|
||||
},
|
||||
|
||||
toggle_reference_doc: function (frm) {
|
||||
if (frm.doc.purchase_receipt && frm.doc.purchase_invoice && frm.doc.docstatus === 1) {
|
||||
frm.set_df_property("purchase_invoice", "read_only", 1);
|
||||
frm.set_df_property("purchase_receipt", "read_only", 1);
|
||||
} else if (frm.doc.is_existing_asset || frm.doc.is_composite_asset) {
|
||||
frm.toggle_reqd("purchase_receipt", 0);
|
||||
frm.toggle_reqd("purchase_invoice", 0);
|
||||
} else if (frm.doc.purchase_receipt) {
|
||||
// if purchase receipt link is set then set PI disabled
|
||||
frm.toggle_reqd("purchase_invoice", 0);
|
||||
frm.set_df_property("purchase_invoice", "read_only", 1);
|
||||
} else if (frm.doc.purchase_invoice) {
|
||||
// if purchase invoice link is set then set PR disabled
|
||||
frm.toggle_reqd("purchase_receipt", 0);
|
||||
frm.set_df_property("purchase_receipt", "read_only", 1);
|
||||
} else {
|
||||
frm.toggle_reqd("purchase_receipt", 1);
|
||||
frm.set_df_property("purchase_receipt", "read_only", 0);
|
||||
frm.toggle_reqd("purchase_invoice", 1);
|
||||
frm.set_df_property("purchase_invoice", "read_only", 0);
|
||||
const is_submitted = frm.doc.docstatus === 1;
|
||||
const is_special_asset = frm.doc.is_existing_asset || frm.doc.is_composite_asset;
|
||||
|
||||
const clear_field = (field) => {
|
||||
if (frm.doc[field]) {
|
||||
frm.set_value(field, "");
|
||||
}
|
||||
};
|
||||
|
||||
["purchase_receipt", "purchase_receipt_item", "purchase_invoice", "purchase_invoice_item"].forEach(
|
||||
(field) => {
|
||||
frm.toggle_reqd(field, 0);
|
||||
frm.set_df_property(field, "read_only", 0);
|
||||
}
|
||||
);
|
||||
|
||||
if (is_submitted) {
|
||||
[
|
||||
"purchase_receipt",
|
||||
"purchase_receipt_item",
|
||||
"purchase_invoice",
|
||||
"purchase_invoice_item",
|
||||
].forEach((field) => {
|
||||
frm.set_df_property(field, "read_only", 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_special_asset) {
|
||||
clear_field("purchase_receipt");
|
||||
clear_field("purchase_receipt_item");
|
||||
clear_field("purchase_invoice");
|
||||
clear_field("purchase_invoice_item");
|
||||
return;
|
||||
}
|
||||
|
||||
if (frm.doc.purchase_receipt) {
|
||||
frm.toggle_reqd("purchase_receipt_item", 1);
|
||||
|
||||
["purchase_invoice", "purchase_invoice_item"].forEach((field) => {
|
||||
clear_field(field);
|
||||
frm.set_df_property(field, "read_only", 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (frm.doc.purchase_invoice) {
|
||||
frm.toggle_reqd("purchase_invoice_item", 1);
|
||||
|
||||
["purchase_receipt", "purchase_receipt_item"].forEach((field) => {
|
||||
clear_field(field);
|
||||
frm.set_df_property(field, "read_only", 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
frm.toggle_reqd("purchase_receipt", 1);
|
||||
frm.toggle_reqd("purchase_invoice", 1);
|
||||
},
|
||||
|
||||
make_journal_entry: function (frm) {
|
||||
@@ -274,8 +319,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"] || "",
|
||||
];
|
||||
|
||||
@@ -468,11 +519,9 @@ frappe.ui.form.on("Asset", {
|
||||
is_composite_asset: function (frm) {
|
||||
if (frm.doc.is_composite_asset) {
|
||||
frm.set_value("gross_purchase_amount", 0);
|
||||
frm.set_df_property("gross_purchase_amount", "read_only", 1);
|
||||
} else {
|
||||
frm.set_df_property("gross_purchase_amount", "read_only", 0);
|
||||
}
|
||||
|
||||
frm.trigger("toggle_reference_doc");
|
||||
},
|
||||
|
||||
@@ -536,7 +585,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);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -229,7 +229,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"
|
||||
},
|
||||
{
|
||||
"fieldname": "available_for_use_date",
|
||||
@@ -596,7 +597,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2025-11-17 18:01:51.417942",
|
||||
"modified": "2025-12-23 16:01:10.195932",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
|
||||
@@ -69,7 +69,6 @@ class Asset(AccountsController):
|
||||
default_finance_book: DF.Link | None
|
||||
department: DF.Link | None
|
||||
depr_entry_posting_status: DF.Literal["", "Successful", "Failed"]
|
||||
depreciation_completed: DF.Check
|
||||
depreciation_method: DF.Literal["", "Straight Line", "Double Declining Balance", "Manual"]
|
||||
disposal_date: DF.Date | None
|
||||
finance_books: DF.Table[AssetFinanceBook]
|
||||
@@ -159,6 +158,10 @@ class Asset(AccountsController):
|
||||
self.total_asset_cost = self.gross_purchase_amount + self.additional_asset_cost
|
||||
self.status = self.get_status()
|
||||
|
||||
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()
|
||||
@@ -477,6 +480,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")
|
||||
)
|
||||
@@ -485,6 +489,10 @@ class Asset(AccountsController):
|
||||
row.expected_value_after_useful_life = flt(
|
||||
row.expected_value_after_useful_life, self.precision("gross_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.gross_purchase_amount):
|
||||
frappe.throw(
|
||||
_("Row {0}: Expected Value After Useful Life must be less than Gross Purchase Amount").format(
|
||||
@@ -500,50 +508,71 @@ class Asset(AccountsController):
|
||||
title=_("Invalid Schedule"),
|
||||
)
|
||||
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
|
||||
self.opening_number_of_booked_depreciations = 0
|
||||
else:
|
||||
depreciable_amount = flt(
|
||||
flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life),
|
||||
self.precision("gross_purchase_amount"),
|
||||
)
|
||||
if flt(self.opening_accumulated_depreciation) > depreciable_amount:
|
||||
self.validate_opening_depreciation_values(row)
|
||||
|
||||
def validate_depreciation_start_date(self, row):
|
||||
if row.depreciation_start_date:
|
||||
if getdate(row.depreciation_start_date) < getdate(self.purchase_date):
|
||||
frappe.throw(
|
||||
_("Opening Accumulated Depreciation must be less than or equal to {0}").format(
|
||||
depreciable_amount
|
||||
_("Row #{0}: Next Depreciation Date cannot be before Purchase Date").format(row.idx)
|
||||
)
|
||||
|
||||
if getdate(row.depreciation_start_date) < getdate(self.available_for_use_date):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Next Depreciation Date cannot be before Available-for-use Date").format(
|
||||
row.idx
|
||||
)
|
||||
)
|
||||
|
||||
if self.opening_accumulated_depreciation:
|
||||
if not self.opening_number_of_booked_depreciations:
|
||||
frappe.throw(_("Please set Opening Number of Booked Depreciations"))
|
||||
else:
|
||||
self.opening_number_of_booked_depreciations = 0
|
||||
|
||||
if flt(row.total_number_of_depreciations) <= cint(self.opening_number_of_booked_depreciations):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Total Number of Depreciations cannot be less than or equal to Opening Number of Booked Depreciations"
|
||||
).format(row.idx),
|
||||
title=_("Invalid Schedule"),
|
||||
)
|
||||
|
||||
if row.depreciation_start_date and getdate(row.depreciation_start_date) < getdate(self.purchase_date):
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Depreciation Row {0}: Next Depreciation Date cannot be before Purchase Date").format(
|
||||
row.idx
|
||||
_("Row #{0}: Depreciation Start Date is required").format(row.idx),
|
||||
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_opening_depreciation_values(self, row):
|
||||
row.expected_value_after_useful_life = flt(
|
||||
row.expected_value_after_useful_life, self.precision("gross_purchase_amount")
|
||||
)
|
||||
|
||||
depreciable_amount = flt(
|
||||
flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life),
|
||||
self.precision("gross_purchase_amount"),
|
||||
)
|
||||
if flt(self.opening_accumulated_depreciation) > depreciable_amount:
|
||||
frappe.throw(
|
||||
_("Opening Accumulated Depreciation must be less than or equal to {0}").format(
|
||||
depreciable_amount
|
||||
)
|
||||
)
|
||||
|
||||
if row.depreciation_start_date and getdate(row.depreciation_start_date) < getdate(
|
||||
self.available_for_use_date
|
||||
):
|
||||
if self.opening_accumulated_depreciation:
|
||||
if not self.opening_number_of_booked_depreciations:
|
||||
frappe.throw(_("Please set Opening Number of Booked Depreciations"))
|
||||
else:
|
||||
self.opening_number_of_booked_depreciations = 0
|
||||
|
||||
if flt(row.total_number_of_depreciations) <= cint(self.opening_number_of_booked_depreciations):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Depreciation Row {0}: Next Depreciation Date cannot be before Available-for-use Date"
|
||||
).format(row.idx)
|
||||
"Row {0}: Total Number of Depreciations cannot be less than or equal to Opening Number of Booked Depreciations"
|
||||
).format(row.idx),
|
||||
title=_("Invalid Schedule"),
|
||||
)
|
||||
|
||||
def set_total_booked_depreciations(self):
|
||||
@@ -640,7 +669,10 @@ class Asset(AccountsController):
|
||||
def get_status(self):
|
||||
"""Returns status based on whether it is draft, submitted, scrapped or depreciated"""
|
||||
if self.docstatus == 0:
|
||||
status = "Draft"
|
||||
if self.is_composite_asset:
|
||||
status = "Work In Progress"
|
||||
else:
|
||||
status = "Draft"
|
||||
elif self.docstatus == 1:
|
||||
status = "Submitted"
|
||||
|
||||
@@ -1206,7 +1238,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
|
||||
return {
|
||||
"company": purchase_doc.company,
|
||||
"purchase_date": purchase_doc.get("bill_date") or purchase_doc.get("posting_date"),
|
||||
"purchase_date": purchase_doc.get("posting_date"),
|
||||
"gross_purchase_amount": flt(first_item.base_net_amount),
|
||||
"asset_quantity": first_item.qty,
|
||||
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
|
||||
|
||||
@@ -537,6 +537,7 @@ def modify_depreciation_schedule_for_asset_repairs(asset, notes):
|
||||
for repair in asset_repairs:
|
||||
if repair.increase_in_asset_life:
|
||||
asset_repair = frappe.get_doc("Asset Repair", repair.name)
|
||||
asset_repair.asset_doc = asset
|
||||
asset_repair.modify_depreciation_schedule()
|
||||
make_new_active_asset_depr_schedules_and_cancel_current_ones(asset, notes)
|
||||
|
||||
|
||||
@@ -197,6 +197,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") {
|
||||
@@ -410,6 +417,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) {
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"default": "1",
|
||||
"fieldname": "target_qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 1,
|
||||
"label": "Target Qty"
|
||||
},
|
||||
{
|
||||
@@ -290,10 +291,10 @@
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
@@ -324,7 +325,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-08 13:14:33.008458",
|
||||
"modified": "2026-01-13 17:25:01.352568",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Capitalization",
|
||||
@@ -362,10 +363,11 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title",
|
||||
"track_changes": 1,
|
||||
"track_seen": 1
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ class AssetCapitalization(StockController):
|
||||
naming_series: DF.Literal["ACC-ASC-.YYYY.-"]
|
||||
posting_date: DF.Date
|
||||
posting_time: DF.Time
|
||||
project: DF.Link | None
|
||||
service_items: DF.Table[AssetCapitalizationServiceItem]
|
||||
service_items_total: DF.Currency
|
||||
set_posting_time: DF.Check
|
||||
@@ -139,6 +140,7 @@ class AssetCapitalization(StockController):
|
||||
self.make_gl_entries()
|
||||
self.repost_future_sle_and_gle()
|
||||
self.restore_consumed_asset_items()
|
||||
self.update_target_asset()
|
||||
|
||||
def set_title(self):
|
||||
self.title = self.target_asset_name or self.target_item_name or self.target_item_code
|
||||
@@ -362,6 +364,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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -607,11 +610,22 @@ class AssetCapitalization(StockController):
|
||||
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
|
||||
|
||||
asset_doc = frappe.get_doc("Asset", self.target_asset)
|
||||
asset_doc.gross_purchase_amount += total_target_asset_value
|
||||
asset_doc.purchase_amount += total_target_asset_value
|
||||
asset_doc.set_status("Work In Progress")
|
||||
asset_doc.flags.ignore_validate = True
|
||||
asset_doc.save()
|
||||
if self.docstatus == 2:
|
||||
gross_purchase_amount = asset_doc.gross_purchase_amount - total_target_asset_value
|
||||
purchase_amount = asset_doc.purchase_amount - total_target_asset_value
|
||||
total_asset_cost = asset_doc.total_asset_cost - total_target_asset_value
|
||||
else:
|
||||
gross_purchase_amount = asset_doc.gross_purchase_amount + total_target_asset_value
|
||||
purchase_amount = asset_doc.purchase_amount + total_target_asset_value
|
||||
total_asset_cost = asset_doc.total_asset_cost + total_target_asset_value
|
||||
|
||||
asset_doc.db_set(
|
||||
{
|
||||
"gross_purchase_amount": gross_purchase_amount,
|
||||
"purchase_amount": purchase_amount,
|
||||
"total_asset_cost": total_asset_cost,
|
||||
}
|
||||
)
|
||||
|
||||
frappe.msgprint(
|
||||
_("Asset {0} has been updated. Please set the depreciation details if any and submit it.").format(
|
||||
@@ -758,6 +772,7 @@ def get_consumed_stock_item_details(args):
|
||||
"company": args.company,
|
||||
"serial_no": args.serial_no,
|
||||
"batch_no": args.batch_no,
|
||||
"serial_and_batch_bundle": args.serial_and_batch_bundle,
|
||||
}
|
||||
)
|
||||
out.update(get_warehouse_details(incoming_rate_args))
|
||||
|
||||
@@ -63,14 +63,7 @@ frappe.ui.form.on("Asset Repair", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus) {
|
||||
frm.add_custom_button(__("View General Ledger"), function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
}
|
||||
frm.events.show_general_ledger(frm);
|
||||
|
||||
let sbb_field = frm.get_docfield("stock_items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
@@ -134,6 +127,26 @@ frappe.ui.form.on("Asset Repair", {
|
||||
frm.set_value("repair_cost", 0);
|
||||
}
|
||||
},
|
||||
|
||||
show_general_ledger: (frm) => {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(
|
||||
__("Accounting Ledger"),
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
},
|
||||
__("View")
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Asset Repair Consumed Item", {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"autoname": "naming_series:",
|
||||
"creation": "2017-10-23 11:38:54.004355",
|
||||
"doctype": "DocType",
|
||||
@@ -250,7 +251,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-17 18:35:54.575265",
|
||||
"modified": "2026-01-06 15:48:13.862505",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset Repair",
|
||||
@@ -264,6 +265,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -279,6 +281,7 @@
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"import": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
@@ -295,4 +298,4 @@
|
||||
"title_field": "asset_name",
|
||||
"track_changes": 1,
|
||||
"track_seen": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ from frappe import _
|
||||
from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, time_diff_in_hours
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
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
|
||||
@@ -246,6 +249,12 @@ class AssetRepair(AccountsController):
|
||||
)
|
||||
stock_entry.asset_repair = self.name
|
||||
|
||||
accounting_dimensions = {
|
||||
"cost_center": self.cost_center,
|
||||
"project": self.project,
|
||||
**{dimension: self.get(dimension) for dimension in get_accounting_dimensions()},
|
||||
}
|
||||
|
||||
for stock_item in self.get("stock_items"):
|
||||
self.validate_serial_no(stock_item)
|
||||
|
||||
@@ -257,8 +266,7 @@ class AssetRepair(AccountsController):
|
||||
"qty": stock_item.consumed_quantity,
|
||||
"basic_rate": stock_item.valuation_rate,
|
||||
"serial_and_batch_bundle": stock_item.serial_and_batch_bundle,
|
||||
"cost_center": self.cost_center,
|
||||
"project": self.project,
|
||||
**accounting_dimensions,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -315,7 +323,8 @@ class AssetRepair(AccountsController):
|
||||
"voucher_no": self.name,
|
||||
"cost_center": self.cost_center,
|
||||
"posting_date": self.completion_date,
|
||||
"against_voucher_type": "Purchase Invoice",
|
||||
"against_voucher_type": "Asset",
|
||||
"against_voucher": self.asset,
|
||||
"company": self.company,
|
||||
},
|
||||
item=self,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import add_days, add_months, flt, get_first_day, nowdate, nowtime, today
|
||||
|
||||
from erpnext.assets.doctype.asset.asset import (
|
||||
@@ -294,6 +296,31 @@ class TestAssetRepair(unittest.TestCase):
|
||||
stock_entry = frappe.get_last_doc("Stock Entry")
|
||||
self.assertEqual(stock_entry.asset_repair, asset_repair.name)
|
||||
|
||||
def test_gl_entries_with_capitalized_asset_repair(self):
|
||||
asset = create_asset(is_existing_asset=1, calculate_depreciation=1, submit=1)
|
||||
asset_repair = create_asset_repair(
|
||||
asset=asset, capitalize_repair_cost=1, item="_Test Non Stock Item", submit=1
|
||||
)
|
||||
asset.reload()
|
||||
|
||||
GLEntry = qb.DocType("GL Entry")
|
||||
res = (
|
||||
qb.from_(GLEntry)
|
||||
.select(Sum(GLEntry.debit_in_account_currency).as_("total_debit"))
|
||||
.where(
|
||||
(GLEntry.voucher_type == "Asset Repair")
|
||||
& (GLEntry.voucher_no == asset_repair.name)
|
||||
& (GLEntry.against_voucher_type == "Asset")
|
||||
& (GLEntry.against_voucher == asset.name)
|
||||
& (GLEntry.company == asset.company)
|
||||
& (GLEntry.is_cancelled == 0)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
booked_value = res[0].total_debit if res else 0
|
||||
|
||||
self.assertEqual(asset.additional_asset_cost, asset_repair.repair_cost)
|
||||
self.assertEqual(booked_value, asset_repair.repair_cost)
|
||||
|
||||
|
||||
def num_of_depreciations(asset):
|
||||
return asset.finance_books[0].total_number_of_depreciations
|
||||
|
||||
@@ -57,7 +57,7 @@ class AssetValueAdjustment(Document):
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
frappe.get_doc("Journal Entry", self.journal_entry).cancel()
|
||||
self.cancel_asset_revaluation_entry()
|
||||
self.update_asset()
|
||||
add_asset_activity(
|
||||
self.asset,
|
||||
@@ -159,6 +159,16 @@ class AssetValueAdjustment(Document):
|
||||
|
||||
self.db_set("journal_entry", je.name)
|
||||
|
||||
def cancel_asset_revaluation_entry(self):
|
||||
if not self.journal_entry:
|
||||
return
|
||||
|
||||
revaluation_entry = frappe.get_doc("Journal Entry", self.journal_entry)
|
||||
if revaluation_entry.docstatus == 1:
|
||||
revaluation_entry.flags.ignore_permissions = True
|
||||
revaluation_entry.flags.via_asset_value_adjustment = True
|
||||
revaluation_entry.cancel()
|
||||
|
||||
def update_asset(self, asset_value=None):
|
||||
difference_amount = self.difference_amount if self.docstatus == 1 else -1 * self.difference_amount
|
||||
asset = self.update_asset_value_after_depreciation(difference_amount)
|
||||
|
||||
@@ -794,7 +794,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
|
||||
@frappe.whitelist()
|
||||
def make_purchase_invoice_from_portal(purchase_order_name):
|
||||
doc = get_mapped_purchase_invoice(purchase_order_name, ignore_permissions=True)
|
||||
if doc.contact_email != frappe.session.user:
|
||||
if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"):
|
||||
frappe.throw(_("Not Permitted"), frappe.PermissionError)
|
||||
doc.save()
|
||||
frappe.db.commit()
|
||||
|
||||
@@ -1297,6 +1297,55 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
pi = make_pi_from_po(po.name)
|
||||
self.assertEqual(pi.items[0].qty, 50)
|
||||
|
||||
def test_multiple_advances_against_purchase_order_are_allocated_across_partial_purchase_invoices(self):
|
||||
# step - 1: create PO
|
||||
po = create_purchase_order(qty=10, rate=10)
|
||||
|
||||
# step - 2: create first partial advance payment
|
||||
pe1 = get_payment_entry("Purchase Order", po.name, bank_account="_Test Bank - _TC")
|
||||
pe1.reference_no = "1"
|
||||
pe1.reference_date = nowdate()
|
||||
pe1.paid_amount = 50
|
||||
pe1.references[0].allocated_amount = 50
|
||||
pe1.save(ignore_permissions=True).submit()
|
||||
|
||||
# check first advance paid against PO
|
||||
po.reload()
|
||||
self.assertEqual(po.advance_paid, 50)
|
||||
|
||||
# step - 3: create first PI for partial qty and allocate first advance
|
||||
pi_1 = make_pi_from_po(po.name)
|
||||
pi_1.update_stock = 1
|
||||
pi_1.allocate_advances_automatically = 1
|
||||
pi_1.items[0].qty = 5
|
||||
pi_1.save(ignore_permissions=True).submit()
|
||||
|
||||
# step - 4: create second advance payment for remaining
|
||||
pe2 = get_payment_entry("Purchase Order", po.name, bank_account="_Test Bank - _TC")
|
||||
pe2.reference_no = "2"
|
||||
pe2.reference_date = nowdate()
|
||||
pe2.paid_amount = 50
|
||||
pe2.references[0].allocated_amount = 50
|
||||
pe2.save(ignore_permissions=True).submit()
|
||||
|
||||
# check second advance paid against PO
|
||||
po.reload()
|
||||
self.assertEqual(po.advance_paid, 100)
|
||||
|
||||
# step - 5: create second PI for remaining qty and allocate second advance
|
||||
pi_2 = make_pi_from_po(po.name)
|
||||
pi_2.update_stock = 1
|
||||
pi_2.allocate_advances_automatically = 1
|
||||
pi_2.save(ignore_permissions=True).submit()
|
||||
|
||||
# check PO and PI status
|
||||
po.reload()
|
||||
pi_1.reload()
|
||||
pi_2.reload()
|
||||
self.assertEqual(pi_1.status, "Paid")
|
||||
self.assertEqual(pi_2.status, "Paid")
|
||||
self.assertEqual(po.status, "Completed")
|
||||
|
||||
|
||||
def create_po_for_sc_testing():
|
||||
from erpnext.controllers.tests.test_subcontracting_controller import (
|
||||
|
||||
@@ -34,15 +34,6 @@ frappe.ui.form.on("Request for Quotation", {
|
||||
});
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
if (!frm.doc.message_for_supplier) {
|
||||
frm.set_value(
|
||||
"message_for_supplier",
|
||||
__("Please supply the specified items at the best possible rates")
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
refresh: function (frm, cdt, cdn) {
|
||||
if (frm.doc.docstatus === 1) {
|
||||
frm.add_custom_button(
|
||||
@@ -248,6 +239,25 @@ frappe.ui.form.on("Request for Quotation", {
|
||||
}
|
||||
refresh_field("items");
|
||||
},
|
||||
|
||||
email_template(frm) {
|
||||
if (frm.doc.email_template) {
|
||||
frappe.db
|
||||
.get_value("Email Template", frm.doc.email_template, [
|
||||
"use_html",
|
||||
"response",
|
||||
"response_html",
|
||||
"subject",
|
||||
])
|
||||
.then((r) => {
|
||||
frm.set_value(
|
||||
"message_for_supplier",
|
||||
r.message.use_html ? r.message.response_html : r.message.response
|
||||
);
|
||||
frm.set_value("subject", r.message.subject);
|
||||
});
|
||||
}
|
||||
},
|
||||
preview: (frm) => {
|
||||
let dialog = new frappe.ui.Dialog({
|
||||
title: __("Preview Email"),
|
||||
@@ -555,7 +565,10 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
|
||||
doctype: "Supplier",
|
||||
order_by: "name",
|
||||
fields: ["name"],
|
||||
filters: [["Supplier", "supplier_group", "=", args.supplier_group]],
|
||||
filters: [
|
||||
["Supplier", "supplier_group", "=", args.supplier_group],
|
||||
["disabled", "=", 0],
|
||||
],
|
||||
},
|
||||
callback: load_suppliers,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"send_attached_files",
|
||||
"send_document_print",
|
||||
"sec_break_email_2",
|
||||
"subject",
|
||||
"message_for_supplier",
|
||||
"terms_section_break",
|
||||
"incoterm",
|
||||
@@ -126,6 +127,7 @@
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.suppliers.some((item) => item.send_email) && !(doc.docstatus == 1 && !doc.email_template)",
|
||||
"fieldname": "supplier_response_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Email Details"
|
||||
@@ -139,8 +141,7 @@
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fetch_from": "email_template.response",
|
||||
"fetch_if_empty": 1,
|
||||
"default": "Please supply the specified items at the best possible rates",
|
||||
"fieldname": "message_for_supplier",
|
||||
"fieldtype": "Text Editor",
|
||||
"in_list_view": 1,
|
||||
@@ -251,7 +252,7 @@
|
||||
"label": "Preview Email"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"depends_on": "eval:doc.suppliers.some((item) => item.send_email)",
|
||||
"fieldname": "sec_break_email_2",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
@@ -315,6 +316,14 @@
|
||||
"hidden": 1,
|
||||
"label": "Has Unit Price Items",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"default": "Request for Quotation",
|
||||
"fieldname": "subject",
|
||||
"fieldtype": "Data",
|
||||
"label": "Subject",
|
||||
"not_nullable": 1,
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -322,7 +331,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-03 16:48:39.856779",
|
||||
"modified": "2026-01-05 14:27:33.329810",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation",
|
||||
@@ -393,4 +402,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ class RequestforQuotation(BuyingController):
|
||||
send_attached_files: DF.Check
|
||||
send_document_print: DF.Check
|
||||
status: DF.Literal["", "Draft", "Submitted", "Cancelled"]
|
||||
subject: DF.Data
|
||||
suppliers: DF.Table[RequestforQuotationSupplier]
|
||||
tc_name: DF.Link | None
|
||||
terms: DF.TextEditor | None
|
||||
@@ -66,6 +67,7 @@ class RequestforQuotation(BuyingController):
|
||||
def before_validate(self):
|
||||
self.set_has_unit_price_items()
|
||||
self.flags.allow_zero_qty = self.has_unit_price_items
|
||||
self.set_data_for_supplier()
|
||||
|
||||
def validate(self):
|
||||
self.validate_duplicate_supplier()
|
||||
@@ -90,6 +92,19 @@ class RequestforQuotation(BuyingController):
|
||||
not row.qty for row in self.get("items") if (row.item_code and not row.qty)
|
||||
)
|
||||
|
||||
def set_data_for_supplier(self):
|
||||
if self.email_template:
|
||||
data = frappe.get_value(
|
||||
"Email Template",
|
||||
self.email_template,
|
||||
["use_html", "response", "response_html", "subject"],
|
||||
as_dict=True,
|
||||
)
|
||||
if not self.message_for_supplier:
|
||||
self.message_for_supplier = data.response_html if data.use_html else data.response
|
||||
if not self.subject:
|
||||
self.subject = data.subject
|
||||
|
||||
def validate_duplicate_supplier(self):
|
||||
supplier_list = [d.supplier for d in self.suppliers]
|
||||
if len(supplier_list) != len(set(supplier_list)):
|
||||
@@ -283,12 +298,6 @@ class RequestforQuotation(BuyingController):
|
||||
}
|
||||
)
|
||||
|
||||
if not self.email_template:
|
||||
return
|
||||
|
||||
email_template = frappe.get_doc("Email Template", self.email_template)
|
||||
message = frappe.render_template(email_template.response_, doc_args)
|
||||
subject = frappe.render_template(email_template.subject, doc_args)
|
||||
fixed_procurement_email = frappe.db.get_single_value("Buying Settings", "fixed_email")
|
||||
if fixed_procurement_email:
|
||||
sender = frappe.db.get_value("Email Account", fixed_procurement_email, "email_id")
|
||||
@@ -296,7 +305,12 @@ class RequestforQuotation(BuyingController):
|
||||
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None
|
||||
|
||||
if preview:
|
||||
return {"message": message, "subject": subject}
|
||||
return {
|
||||
"message": self.message_for_supplier,
|
||||
"subject": self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
or _("Request for Quotation"),
|
||||
}
|
||||
|
||||
attachments = []
|
||||
if self.send_attached_files:
|
||||
@@ -316,7 +330,15 @@ class RequestforQuotation(BuyingController):
|
||||
)
|
||||
)
|
||||
|
||||
self.send_email(data, sender, subject, message, attachments)
|
||||
self.send_email(
|
||||
data,
|
||||
sender,
|
||||
self.subject
|
||||
or frappe.get_value("Email Template", self.email_template, "subject")
|
||||
or _("Request for Quotation"),
|
||||
self.message_for_supplier,
|
||||
attachments,
|
||||
)
|
||||
|
||||
def send_email(self, data, sender, subject, message, attachments):
|
||||
make(
|
||||
|
||||
@@ -80,20 +80,21 @@
|
||||
"fieldname": "email_id",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Email Id",
|
||||
"label": "Email ID",
|
||||
"no_copy": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2020-11-04 22:01:43.832942",
|
||||
"modified": "2026-01-05 14:08:27.274538",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation Supplier",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,14 @@ frappe.ui.form.on("Supplier", {
|
||||
// indicators
|
||||
erpnext.utils.set_party_dashboard_indicators(frm);
|
||||
}
|
||||
|
||||
frm.set_query("supplier_group", () => {
|
||||
return {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
get_supplier_group_details: function (frm) {
|
||||
frappe.call({
|
||||
|
||||
@@ -14,6 +14,7 @@ from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_
|
||||
from erpnext.accounts.party import (
|
||||
get_dashboard_info,
|
||||
validate_party_accounts,
|
||||
validate_party_currency_before_merging,
|
||||
)
|
||||
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
@@ -208,6 +209,10 @@ class Supplier(TransactionBase):
|
||||
|
||||
delete_contact_and_address("Supplier", self.name)
|
||||
|
||||
def before_rename(self, olddn, newdn, merge=False):
|
||||
if merge:
|
||||
validate_party_currency_before_merging("Supplier", olddn, newdn)
|
||||
|
||||
def after_rename(self, olddn, newdn, merge=False):
|
||||
if frappe.defaults.get_global_default("supp_master_name") == "Supplier Name":
|
||||
self.db_set("supplier_name", newdn)
|
||||
|
||||
9
erpnext/change_log/v15/v15_94_2.md
Normal file
9
erpnext/change_log/v15/v15_94_2.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Version 16 Released!
|
||||
|
||||
ERPNext version 16 has been released!
|
||||
|
||||
Since it's the latest version of ERPNext, we recommend that you update to it to get the latest features, bug fixes and other improvements.
|
||||
|
||||
[Click here to know more about v16](https://frappe.io/erpnext/version-16)
|
||||
|
||||
If you're on [Frappe Cloud](https://frappe.io/cloud), [click here to learn how to update to v16](https://docs.frappe.io/cloud/sites/version-upgrade)
|
||||
@@ -184,9 +184,8 @@ class AccountsController(TransactionBase):
|
||||
|
||||
msg = ""
|
||||
if self.get("update_outstanding_for_self"):
|
||||
msg = (
|
||||
"We can see {0} is made against {1}. If you want {1}'s outstanding to be updated, "
|
||||
"uncheck '{2}' checkbox. <br><br>Or"
|
||||
msg = _(
|
||||
"We can see {0} is made against {1}. If you want {1}'s outstanding to be updated, uncheck the '{2}' checkbox."
|
||||
).format(
|
||||
frappe.bold(document_type),
|
||||
get_link_to_form(self.doctype, self.get("return_against")),
|
||||
@@ -197,8 +196,8 @@ class AccountsController(TransactionBase):
|
||||
abs(flt(self.rounded_total) or flt(self.grand_total)) > flt(against_voucher_outstanding)
|
||||
):
|
||||
self.update_outstanding_for_self = 1
|
||||
msg = (
|
||||
"The outstanding amount {} in {} is lesser than {}. Updating the outstanding to this invoice. <br><br>And"
|
||||
msg = _(
|
||||
"The outstanding amount {0} in {1} is lesser than {2}. Updating the outstanding to this invoice."
|
||||
).format(
|
||||
against_voucher_outstanding,
|
||||
get_link_to_form(self.doctype, self.get("return_against")),
|
||||
@@ -206,11 +205,11 @@ class AccountsController(TransactionBase):
|
||||
)
|
||||
|
||||
if msg:
|
||||
msg += " you can use {} tool to reconcile against {} later.".format(
|
||||
msg += "<br><br>" + _("You can use {0} to reconcile against {1} later.").format(
|
||||
get_link_to_form("Payment Reconciliation", "Payment Reconciliation"),
|
||||
get_link_to_form(self.doctype, self.get("return_against")),
|
||||
)
|
||||
frappe.msgprint(_(msg))
|
||||
frappe.msgprint(msg)
|
||||
|
||||
def validate(self):
|
||||
if not self.get("is_return") and not self.get("is_debit_note"):
|
||||
@@ -308,6 +307,52 @@ class AccountsController(TransactionBase):
|
||||
self.set_default_letter_head()
|
||||
self.validate_company_in_accounting_dimension()
|
||||
self.validate_party_address_and_contact()
|
||||
self.validate_company_linked_addresses()
|
||||
|
||||
def validate_company_linked_addresses(self):
|
||||
address_fields = []
|
||||
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_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",
|
||||
{
|
||||
"parent": address,
|
||||
"parenttype": "Address",
|
||||
"link_doctype": "Company",
|
||||
"link_name": self.company,
|
||||
},
|
||||
):
|
||||
frappe.throw(
|
||||
_("{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:
|
||||
@@ -362,6 +407,24 @@ class AccountsController(TransactionBase):
|
||||
|
||||
for _doctype in repost_doctypes:
|
||||
dt = frappe.qb.DocType(_doctype)
|
||||
|
||||
cancelled_entries = (
|
||||
frappe.qb.from_(dt)
|
||||
.select(dt.parent, dt.parenttype)
|
||||
.where((dt.voucher_type == self.doctype) & (dt.voucher_no == self.name) & (dt.docstatus == 2))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if cancelled_entries:
|
||||
entries = "<br>".join([get_link_to_form(d.parenttype, d.parent) for d in cancelled_entries])
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"The following cancelled repost entries exist for <b>{0}</b>:<br><br>{1}<br><br>"
|
||||
"Kindly delete these entries before continuing."
|
||||
).format(self.name, entries)
|
||||
)
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(dt)
|
||||
.select(dt.name, dt.parent, dt.parenttype)
|
||||
@@ -3650,9 +3713,9 @@ def validate_child_on_delete(row, parent):
|
||||
)
|
||||
if flt(row.ordered_qty):
|
||||
frappe.throw(
|
||||
_("Row #{0}: Cannot delete item {1} which is assigned to customer's purchase order.").format(
|
||||
row.idx, row.item_code
|
||||
)
|
||||
_(
|
||||
"Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
|
||||
).format(row.idx, row.item_code)
|
||||
)
|
||||
|
||||
if parent.doctype == "Purchase Order" and flt(row.received_qty):
|
||||
|
||||
@@ -11,7 +11,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime
|
||||
import erpnext
|
||||
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle
|
||||
from erpnext.stock.utils import get_incoming_rate, get_valuation_method
|
||||
from erpnext.stock.utils import get_incoming_rate, get_valuation_method, getdate
|
||||
|
||||
|
||||
class StockOverReturnError(frappe.ValidationError):
|
||||
@@ -185,7 +185,7 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items):
|
||||
frappe.get_meta(doc.doctype + " Item").get_field(
|
||||
"stock_qty" if doc.get("update_stock", "") else "qty"
|
||||
),
|
||||
company_currency,
|
||||
currency=company_currency,
|
||||
)
|
||||
|
||||
for column in fields:
|
||||
@@ -683,6 +683,29 @@ def get_rate_for_return(
|
||||
else:
|
||||
select_field = "abs(stock_value_difference / actual_qty)"
|
||||
|
||||
item_details = frappe.get_cached_value("Item", item_code, ["has_batch_no", "has_expiry_date"], as_dict=1)
|
||||
set_zero_rate_for_expired_batch = frappe.db.get_single_value(
|
||||
"Selling Settings", "set_zero_rate_for_expired_batch"
|
||||
)
|
||||
|
||||
if (
|
||||
set_zero_rate_for_expired_batch
|
||||
and item_details.has_batch_no
|
||||
and item_details.has_expiry_date
|
||||
and not return_against
|
||||
and voucher_type in ["Sales Invoice", "Delivery Note"]
|
||||
):
|
||||
# set incoming_rate zero explicitly for standalone credit note with expired batch
|
||||
batch_no = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "batch_no")
|
||||
if batch_no and is_batch_expired(batch_no, sle.get("posting_date")):
|
||||
frappe.db.set_value(
|
||||
voucher_type + " Item",
|
||||
voucher_detail_no,
|
||||
"incoming_rate",
|
||||
0,
|
||||
)
|
||||
return 0
|
||||
|
||||
rate = flt(frappe.db.get_value("Stock Ledger Entry", filters, select_field))
|
||||
if not (rate and return_against) and voucher_type in ["Sales Invoice", "Delivery Note"]:
|
||||
rate = frappe.db.get_value(f"{voucher_type} Item", voucher_detail_no, "incoming_rate")
|
||||
@@ -1152,3 +1175,17 @@ def get_available_serial_nos(serial_nos, warehouse):
|
||||
def get_payment_data(invoice):
|
||||
payment = frappe.db.get_all("Sales Invoice Payment", {"parent": invoice}, ["mode_of_payment", "amount"])
|
||||
return payment
|
||||
|
||||
|
||||
def is_batch_expired(batch_no, posting_date):
|
||||
"""
|
||||
To check whether the batch is expired or not based on the posting date.
|
||||
"""
|
||||
expiry_date = frappe.db.get_value("Batch", batch_no, "expiry_date")
|
||||
if not expiry_date:
|
||||
return
|
||||
|
||||
if getdate(posting_date) > getdate(expiry_date):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.utils import cint, flt, get_link_to_form, nowtime
|
||||
|
||||
from erpnext.accounts.party import render_address
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges
|
||||
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
|
||||
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, is_batch_expired
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.doctype.item.item import set_item_default
|
||||
from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor
|
||||
@@ -521,16 +521,31 @@ class SellingController(StockController):
|
||||
allow_at_arms_length_price = frappe.get_cached_value(
|
||||
"Stock Settings", None, "allow_internal_transfer_at_arms_length_price"
|
||||
)
|
||||
set_zero_rate_for_expired_batch = frappe.db.get_single_value(
|
||||
"Selling Settings", "set_zero_rate_for_expired_batch"
|
||||
)
|
||||
|
||||
items = self.get("items") + (self.get("packed_items") or [])
|
||||
for d in items:
|
||||
if not frappe.get_cached_value("Item", d.item_code, "is_stock_item"):
|
||||
continue
|
||||
|
||||
item_details = frappe.get_cached_value(
|
||||
"Item", d.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
|
||||
"Item", d.item_code, ["has_serial_no", "has_batch_no", "has_expiry_date"], as_dict=1
|
||||
)
|
||||
|
||||
if not self.get("return_against") or (
|
||||
if (
|
||||
set_zero_rate_for_expired_batch
|
||||
and item_details.has_batch_no
|
||||
and item_details.has_expiry_date
|
||||
and self.get("is_return")
|
||||
and not self.get("return_against")
|
||||
and is_batch_expired(d.batch_no, self.get("posting_date"))
|
||||
):
|
||||
# set incoming rate as zero for stand-lone credit note with expired batch
|
||||
d.incoming_rate = 0
|
||||
|
||||
elif not self.get("return_against") or (
|
||||
get_valuation_method(d.item_code) == "Moving Average"
|
||||
and self.get("is_return")
|
||||
and not item_details.has_serial_no
|
||||
@@ -1004,10 +1019,19 @@ class SellingController(StockController):
|
||||
|
||||
|
||||
def set_default_income_account_for_item(obj):
|
||||
for d in obj.get("items"):
|
||||
if d.item_code:
|
||||
if getattr(d, "income_account", None):
|
||||
set_item_default(d.item_code, obj.company, "income_account", d.income_account)
|
||||
"""Set income account as default for items in the transaction.
|
||||
|
||||
Updates the item default income account for each item in the transaction
|
||||
if it differs from the company's default income account.
|
||||
|
||||
Args:
|
||||
obj: Transaction document containing items table with income_account field
|
||||
"""
|
||||
company_default = frappe.get_cached_value("Company", obj.company, "default_income_account")
|
||||
for d in obj.get("items", default=[]):
|
||||
income_account = getattr(d, "income_account", None)
|
||||
if d.item_code and income_account and income_account != company_default:
|
||||
set_item_default(d.item_code, obj.company, "income_account", income_account)
|
||||
|
||||
|
||||
def get_serial_and_batch_bundle(child, parent, delivery_note_child=None):
|
||||
|
||||
@@ -86,6 +86,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"],
|
||||
],
|
||||
|
||||
@@ -465,7 +465,7 @@ class StockController(AccountsController):
|
||||
if is_rejected:
|
||||
serial_nos = row.get("rejected_serial_no")
|
||||
type_of_transaction = "Inward" if not self.is_return else "Outward"
|
||||
qty = row.get("rejected_qty")
|
||||
qty = row.get("rejected_qty") * row.get("conversion_factor", 1.0)
|
||||
warehouse = row.get("rejected_warehouse")
|
||||
|
||||
if (
|
||||
|
||||
@@ -11,6 +11,7 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
get_auto_batch_nos,
|
||||
get_available_serial_nos,
|
||||
@@ -686,7 +687,11 @@ class SubcontractingController(StockController):
|
||||
serial_nos = get_filtered_serial_nos(serial_nos, self, "supplied_items")
|
||||
row.serial_no = "\n".join(serial_nos)
|
||||
|
||||
elif item_details.has_batch_no and not row.serial_and_batch_bundle and not row.batch_no:
|
||||
elif (
|
||||
item_details.has_batch_no
|
||||
and not row.serial_and_batch_bundle
|
||||
and (not row.batch_no or self.batch_has_not_available(row.batch_no, row.consumed_qty))
|
||||
):
|
||||
batches = get_auto_batch_nos(kwargs)
|
||||
if batches:
|
||||
consumed_qty = row.consumed_qty
|
||||
@@ -711,6 +716,11 @@ class SubcontractingController(StockController):
|
||||
)
|
||||
consumed_qty -= d.get("qty")
|
||||
|
||||
def batch_has_not_available(self, batch_no, qty_required):
|
||||
batch_qty = get_batch_qty(batch_no, self.supplier_warehouse, consider_negative_batches=True)
|
||||
|
||||
return batch_qty < qty_required
|
||||
|
||||
def update_rate_for_supplied_items(self):
|
||||
if self.doctype != "Subcontracting Receipt":
|
||||
return
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import functions
|
||||
from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction
|
||||
from frappe.utils.deprecations import deprecated
|
||||
|
||||
@@ -45,17 +46,23 @@ class calculate_taxes_and_totals:
|
||||
items = list(filter(lambda item: not item.get("is_alternative"), self.doc.get("items")))
|
||||
return items
|
||||
|
||||
def calculate(self):
|
||||
def calculate(self, ignore_tax_template_validation=False):
|
||||
if not len(self.doc.items):
|
||||
return
|
||||
|
||||
self.discount_amount_applied = False
|
||||
self.need_recomputation = False
|
||||
self.ignore_tax_template_validation = ignore_tax_template_validation
|
||||
|
||||
self._calculate()
|
||||
|
||||
if self.doc.meta.get_field("discount_amount"):
|
||||
self.set_discount_amount()
|
||||
self.apply_discount_amount()
|
||||
|
||||
if not ignore_tax_template_validation and self.need_recomputation:
|
||||
return self.calculate(ignore_tax_template_validation=True)
|
||||
|
||||
# Update grand total as per cash and non trade discount
|
||||
if self.doc.apply_discount_on == "Grand Total" and self.doc.get("is_cash_or_non_trade_discount"):
|
||||
self.doc.grand_total -= self.doc.discount_amount
|
||||
@@ -99,6 +106,9 @@ class calculate_taxes_and_totals:
|
||||
self.doc.base_tax_withholding_net_total = sum_base_net_amount
|
||||
|
||||
def validate_item_tax_template(self):
|
||||
if self.ignore_tax_template_validation:
|
||||
return
|
||||
|
||||
if self.doc.get("is_return") and self.doc.get("return_against"):
|
||||
return
|
||||
|
||||
@@ -140,6 +150,10 @@ class calculate_taxes_and_totals:
|
||||
)
|
||||
)
|
||||
|
||||
# For correct tax_amount calculation re-computation is required
|
||||
if self.discount_amount_applied and self.doc.apply_discount_on == "Grand Total":
|
||||
self.need_recomputation = True
|
||||
|
||||
def update_item_tax_map(self):
|
||||
for item in self.doc.items:
|
||||
item.item_tax_rate = get_item_tax_map(
|
||||
@@ -377,6 +391,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
|
||||
@@ -586,7 +603,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
|
||||
@@ -685,6 +702,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)
|
||||
@@ -833,12 +866,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):
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,10 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import prepare_data_for_internal_transfer
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_purchase_order,
|
||||
prepare_data_for_internal_transfer,
|
||||
)
|
||||
from erpnext.projects.doctype.project.test_project import make_project
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
|
||||
@@ -2428,3 +2431,48 @@ class TestAccountsController(FrappeTestCase):
|
||||
|
||||
# Second return should only get remaining discount (100 - 60 = 40)
|
||||
self.assertEqual(return_si_2.discount_amount, -40)
|
||||
|
||||
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"
|
||||
)
|
||||
company_address.append("links", {"link_doctype": "Company", "link_name": "_Test Company"})
|
||||
company_address.save()
|
||||
|
||||
customer_shipping = make_address(
|
||||
address_title="Customer", address_type="Shipping", address_line1="10"
|
||||
)
|
||||
customer_shipping.append("links", {"link_doctype": "Customer", "link_name": "_Test Customer"})
|
||||
customer_shipping.save()
|
||||
|
||||
supplier_billing = make_address(address_title="Supplier", address_line1="2", city="Ahmedabad")
|
||||
supplier_billing.append("links", {"link_doctype": "Supplier", "link_name": "_Test Supplier"})
|
||||
supplier_billing.save()
|
||||
|
||||
po = create_purchase_order(do_not_save=True)
|
||||
po.shipping_address = customer_shipping.name
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
po.shipping_address = company_address.name
|
||||
po.save()
|
||||
|
||||
po.billing_address = supplier_billing.name
|
||||
self.assertRaises(frappe.ValidationError, po.save)
|
||||
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()
|
||||
|
||||
@@ -778,9 +778,8 @@ class TestSubcontractingController(FrappeTestCase):
|
||||
row.serial_no = "ABC"
|
||||
break
|
||||
|
||||
bundle.save()
|
||||
self.assertRaises(frappe.ValidationError, bundle.save)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, scr1.save)
|
||||
bundle.load_from_db()
|
||||
for row in bundle.entries:
|
||||
if row.idx == 1:
|
||||
|
||||
@@ -123,7 +123,7 @@ def send_mail(entry, email_campaign):
|
||||
subject=frappe.render_template(email_template.get("subject"), context),
|
||||
content=frappe.render_template(email_template.response_, context),
|
||||
sender=sender,
|
||||
recipients=recipient_list,
|
||||
bcc=recipient_list,
|
||||
communication_medium="Email",
|
||||
sent_or_received="Sent",
|
||||
send_email=True,
|
||||
|
||||
@@ -340,10 +340,7 @@ doc_events = {
|
||||
"User": {
|
||||
"after_insert": "frappe.contacts.doctype.contact.contact.update_contact",
|
||||
"validate": "erpnext.setup.doctype.employee.employee.validate_employee_role",
|
||||
"on_update": [
|
||||
"erpnext.setup.doctype.employee.employee.update_user_permissions",
|
||||
"erpnext.portal.utils.set_default_role",
|
||||
],
|
||||
"on_update": "erpnext.portal.utils.set_default_role",
|
||||
},
|
||||
"Communication": {
|
||||
"on_update": [
|
||||
@@ -415,29 +412,29 @@ scheduler_events = {
|
||||
"0/15 * * * *": [
|
||||
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
|
||||
],
|
||||
"0/30 * * * *": [
|
||||
"erpnext.utilities.doctype.video.video.update_youtube_data",
|
||||
],
|
||||
"0/30 * * * *": [],
|
||||
# Hourly but offset by 30 minutes
|
||||
"30 * * * *": [
|
||||
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
|
||||
],
|
||||
# Daily but offset by 45 minutes
|
||||
"45 0 * * *": [
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
],
|
||||
"45 0 * * *": [],
|
||||
},
|
||||
"hourly": [
|
||||
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
|
||||
"erpnext.projects.doctype.project.project.project_status_update_reminder",
|
||||
"erpnext.projects.doctype.project.project.hourly_reminder",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
],
|
||||
"hourly_long": [
|
||||
"hourly_long": [],
|
||||
"hourly_maintenance": [
|
||||
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
|
||||
"erpnext.utilities.bulk_transaction.retry",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
"erpnext.projects.doctype.project.project.project_status_update_reminder",
|
||||
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
|
||||
"erpnext.utilities.doctype.video.video.update_youtube_data",
|
||||
],
|
||||
"daily": [
|
||||
"daily": [],
|
||||
"daily_long": [],
|
||||
"daily_maintenance": [
|
||||
"erpnext.support.doctype.issue.issue.auto_close_tickets",
|
||||
"erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity",
|
||||
"erpnext.controllers.accounts_controller.update_invoice_status",
|
||||
@@ -461,17 +458,16 @@ scheduler_events = {
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_daily",
|
||||
"erpnext.accounts.utils.run_ledger_health_checks",
|
||||
"erpnext.assets.doctype.asset_maintenance_log.asset_maintenance_log.update_asset_maintenance_log_status",
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
],
|
||||
"daily_long": [
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
"erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process",
|
||||
"erpnext.setup.doctype.email_digest.email_digest.send",
|
||||
"erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms",
|
||||
"erpnext.crm.utils.open_leads_opportunities_based_on_todays_event",
|
||||
"erpnext.assets.doctype.asset.depreciation.post_depreciation_entries",
|
||||
],
|
||||
"weekly": [
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_weekly",
|
||||
],
|
||||
"monthly_long": [
|
||||
"erpnext.accounts.deferred_revenue.process_deferred_accounting",
|
||||
"erpnext.accounts.utils.auto_create_exchange_rate_revaluation_monthly",
|
||||
@@ -563,6 +559,7 @@ accounting_dimension_doctypes = [
|
||||
"Payment Request",
|
||||
"Asset Movement Item",
|
||||
"Asset Depreciation Schedule",
|
||||
"Advance Taxes and Charges",
|
||||
]
|
||||
|
||||
get_matching_queries = (
|
||||
|
||||
@@ -389,10 +389,12 @@ frappe.ui.form.on("BOM", {
|
||||
);
|
||||
|
||||
has_template_rm.forEach((d) => {
|
||||
let bom_qty = dialog.fields_dict.qty?.value || 1;
|
||||
|
||||
dialog.fields_dict.items.df.data.push({
|
||||
item_code: d.item_code,
|
||||
variant_item_code: "",
|
||||
qty: (d.qty / frm.doc.quantity) * (dialog.fields_dict.qty.value || 1),
|
||||
qty: flt(d.qty / frm.doc.quantity) * flt(bom_qty),
|
||||
source_warehouse: d.source_warehouse,
|
||||
operation: d.operation,
|
||||
});
|
||||
@@ -551,8 +553,6 @@ var get_bom_material_detail = function (doc, cdt, cdn, scrap_items) {
|
||||
do_not_explode: d.do_not_explode,
|
||||
},
|
||||
callback: function (r) {
|
||||
d = locals[cdt][cdn];
|
||||
|
||||
$.extend(d, r.message);
|
||||
refresh_field("items");
|
||||
refresh_field("scrap_items");
|
||||
|
||||
@@ -178,17 +178,12 @@ class JobCard(Document):
|
||||
|
||||
if job_card_qty and ((job_card_qty - completed_qty) > wo_qty):
|
||||
form_link = get_link_to_form("Manufacturing Settings", "Manufacturing Settings")
|
||||
|
||||
msg = f"""
|
||||
Qty To Manufacture in the job card
|
||||
cannot be greater than Qty To Manufacture in the
|
||||
work order for the operation {bold(self.operation)}.
|
||||
<br><br><b>Solution: </b> Either you can reduce the
|
||||
Qty To Manufacture in the job card or set the
|
||||
'Overproduction Percentage For Work Order'
|
||||
in the {form_link}."""
|
||||
|
||||
frappe.throw(_(msg), title=_("Extra Job Card Quantity"))
|
||||
frappe.throw(
|
||||
_(
|
||||
"Qty To Manufacture in the job card cannot be greater than Qty To Manufacture in the work order for the operation {0}. <br><br><b>Solution: </b> Either you can reduce the Qty To Manufacture in the job card or set the 'Overproduction Percentage For Work Order' in the {1}."
|
||||
).format(bold(self.operation), form_link),
|
||||
title=_("Extra Job Card Quantity"),
|
||||
)
|
||||
|
||||
def set_sub_operations(self):
|
||||
if not self.sub_operations and self.operation:
|
||||
@@ -605,7 +600,7 @@ class JobCard(Document):
|
||||
op_row.employee.append(time_log.employee)
|
||||
if time_log.time_in_mins:
|
||||
op_row.completed_time += time_log.time_in_mins
|
||||
op_row.completed_qty += time_log.completed_qty
|
||||
op_row.completed_qty += flt(time_log.completed_qty)
|
||||
|
||||
for row in self.sub_operations:
|
||||
operation_deatils = operation_wise_completed_time.get(row.sub_operation)
|
||||
@@ -1064,14 +1059,16 @@ class JobCard(Document):
|
||||
)
|
||||
|
||||
if row.completed_qty < current_operation_qty:
|
||||
msg = f"""The completed quantity {bold(current_operation_qty)}
|
||||
of an operation {bold(self.operation)} cannot be greater
|
||||
than the completed quantity {bold(row.completed_qty)}
|
||||
of a previous operation
|
||||
{bold(row.operation)}.
|
||||
"""
|
||||
|
||||
frappe.throw(_(msg))
|
||||
frappe.throw(
|
||||
_(
|
||||
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
|
||||
).format(
|
||||
bold(current_operation_qty),
|
||||
bold(self.operation),
|
||||
bold(row.completed_qty),
|
||||
bold(row.operation),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_work_order(self):
|
||||
if self.is_work_order_closed():
|
||||
|
||||
@@ -369,6 +369,15 @@ class ProductionPlan(Document):
|
||||
|
||||
pi = frappe.qb.DocType("Packed Item")
|
||||
|
||||
pending_qty = (
|
||||
frappe.qb.terms.Case()
|
||||
.when(
|
||||
(so_item.work_order_qty > so_item.delivered_qty),
|
||||
(((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty),
|
||||
)
|
||||
.else_(((so_item.qty - so_item.delivered_qty) * pi.qty) / so_item.qty)
|
||||
)
|
||||
|
||||
packed_items_query = (
|
||||
frappe.qb.from_(so_item)
|
||||
.from_(pi)
|
||||
@@ -376,7 +385,7 @@ class ProductionPlan(Document):
|
||||
pi.parent,
|
||||
pi.item_code,
|
||||
pi.warehouse.as_("warehouse"),
|
||||
(((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty).as_("pending_qty"),
|
||||
pending_qty.as_("pending_qty"),
|
||||
pi.parent_item,
|
||||
pi.description,
|
||||
so_item.name,
|
||||
@@ -387,7 +396,16 @@ class ProductionPlan(Document):
|
||||
& (so_item.docstatus == 1)
|
||||
& (pi.parent_item == so_item.item_code)
|
||||
& (so_item.parent.isin(so_list))
|
||||
& (so_item.qty > so_item.work_order_qty)
|
||||
& (
|
||||
(
|
||||
(so_item.work_order_qty > so_item.delivered_qty)
|
||||
& (so_item.qty > so_item.work_order_qty)
|
||||
)
|
||||
| (
|
||||
(so_item.work_order_qty <= so_item.delivered_qty)
|
||||
& (so_item.qty > so_item.delivered_qty)
|
||||
)
|
||||
)
|
||||
& (
|
||||
ExistsCriterion(
|
||||
frappe.qb.from_(bom)
|
||||
@@ -1303,14 +1321,21 @@ def get_material_request_items(
|
||||
include_safety_stock,
|
||||
warehouse,
|
||||
bin_dict,
|
||||
consumed_qty,
|
||||
):
|
||||
total_qty = row["qty"]
|
||||
|
||||
required_qty = 0
|
||||
item_code = row.get("item_code")
|
||||
|
||||
if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0:
|
||||
required_qty = total_qty
|
||||
elif total_qty > bin_dict.get("projected_qty", 0):
|
||||
required_qty = total_qty - bin_dict.get("projected_qty", 0)
|
||||
required_qty = flt(row.get("qty"))
|
||||
else:
|
||||
key = (item_code, warehouse)
|
||||
available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key]
|
||||
if available_qty > 0:
|
||||
required_qty = max(0, flt(row.get("qty")) - available_qty)
|
||||
consumed_qty[key] += min(flt(row.get("qty")), available_qty)
|
||||
else:
|
||||
required_qty = flt(row.get("qty"))
|
||||
|
||||
if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]:
|
||||
required_qty = row["min_order_qty"]
|
||||
@@ -1354,7 +1379,7 @@ def get_material_request_items(
|
||||
"item_name": row.item_name,
|
||||
"quantity": required_qty / conversion_factor,
|
||||
"conversion_factor": conversion_factor,
|
||||
"required_bom_qty": total_qty,
|
||||
"required_bom_qty": row.get("qty"),
|
||||
"stock_uom": row.get("stock_uom"),
|
||||
"warehouse": warehouse
|
||||
or row.get("source_warehouse")
|
||||
@@ -1627,7 +1652,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
"min_order_qty": item_master.min_order_qty,
|
||||
"default_material_request_type": item_master.default_material_request_type,
|
||||
"qty": planned_qty or 1,
|
||||
"is_sub_contracted": item_master.is_subcontracted_item,
|
||||
"is_sub_contracted": item_master.is_sub_contracted_item,
|
||||
"item_code": item_master.name,
|
||||
"description": item_master.description,
|
||||
"stock_uom": item_master.stock_uom,
|
||||
@@ -1648,9 +1673,12 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
so_item_details[sales_order][item_code] = details
|
||||
|
||||
mr_items = []
|
||||
consumed_qty = defaultdict(float)
|
||||
|
||||
for sales_order in so_item_details:
|
||||
item_dict = so_item_details[sales_order]
|
||||
for details in item_dict.values():
|
||||
warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse")
|
||||
bin_dict = get_bin_details(details, doc.company, warehouse)
|
||||
bin_dict = bin_dict[0] if bin_dict else {}
|
||||
|
||||
@@ -1664,6 +1692,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
include_safety_stock,
|
||||
warehouse,
|
||||
bin_dict,
|
||||
consumed_qty,
|
||||
)
|
||||
if items:
|
||||
mr_items.append(items)
|
||||
|
||||
@@ -145,6 +145,84 @@ class TestProductionPlan(FrappeTestCase):
|
||||
sr2.cancel()
|
||||
pln.cancel()
|
||||
|
||||
def test_projected_qty_cascading_across_multiple_sales_orders(self):
|
||||
rm_item = make_item(
|
||||
"_Test RM For Cascading",
|
||||
{"is_stock_item": 1, "valuation_rate": 100},
|
||||
).name
|
||||
|
||||
fg_item_a = make_item(
|
||||
"_Test FG A For Cascading",
|
||||
{"is_stock_item": 1, "valuation_rate": 200},
|
||||
).name
|
||||
|
||||
if not frappe.db.exists("BOM", {"item": fg_item_a, "docstatus": 1}):
|
||||
make_bom(item=fg_item_a, raw_materials=[rm_item], rm_qty=1)
|
||||
|
||||
# Stock for RM
|
||||
sr = create_stock_reconciliation(item_code=rm_item, target="_Test Warehouse - _TC", qty=1, rate=100)
|
||||
|
||||
# Sales orders
|
||||
so1 = make_sales_order(item_code=fg_item_a, qty=1)
|
||||
so2 = make_sales_order(item_code=fg_item_a, qty=1)
|
||||
so3 = make_sales_order(item_code=fg_item_a, qty=1)
|
||||
|
||||
# Production plan
|
||||
pln = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Production Plan",
|
||||
"company": "_Test Company",
|
||||
"posting_date": nowdate(),
|
||||
"get_items_from": "Sales Order",
|
||||
"ignore_existing_ordered_qty": 0,
|
||||
}
|
||||
)
|
||||
pln.append(
|
||||
"sales_orders",
|
||||
{
|
||||
"sales_order": so1.name,
|
||||
"sales_order_date": so1.transaction_date,
|
||||
"customer": so1.customer,
|
||||
"grand_total": so1.grand_total,
|
||||
},
|
||||
)
|
||||
pln.append(
|
||||
"sales_orders",
|
||||
{
|
||||
"sales_order": so2.name,
|
||||
"sales_order_date": so2.transaction_date,
|
||||
"customer": so2.customer,
|
||||
"grand_total": so2.grand_total,
|
||||
},
|
||||
)
|
||||
pln.append(
|
||||
"sales_orders",
|
||||
{
|
||||
"sales_order": so3.name,
|
||||
"sales_order_date": so3.transaction_date,
|
||||
"customer": so3.customer,
|
||||
"grand_total": so3.grand_total,
|
||||
},
|
||||
)
|
||||
|
||||
pln.get_items()
|
||||
pln.insert()
|
||||
|
||||
mr_items = get_items_for_material_requests(pln.as_dict())
|
||||
quantities = [d["quantity"] for d in mr_items]
|
||||
rm_qty = sum(quantities)
|
||||
|
||||
# Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour)
|
||||
self.assertEqual(len(mr_items), 2)
|
||||
self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)")
|
||||
self.assertEqual(
|
||||
quantities,
|
||||
[1, 1],
|
||||
"Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock",
|
||||
)
|
||||
|
||||
sr.cancel()
|
||||
|
||||
def test_production_plan_with_non_stock_item(self):
|
||||
"Test if MR Planning table includes Non Stock RM."
|
||||
pln = create_production_plan(item_code="Test Production Item 1", include_non_stock_items=1)
|
||||
@@ -678,6 +756,109 @@ class TestProductionPlan(FrappeTestCase):
|
||||
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_get_sales_order_items_for_product_bundle(self):
|
||||
"""Testing the Planned Qty for Product Bundle Item"""
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import (
|
||||
make_stock_entry as create_stock_entry,
|
||||
)
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import (
|
||||
make_wo_order_test_record,
|
||||
)
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
# 1. Create required items
|
||||
bundle_item = create_item(item_code="Bundle Item", is_stock_item=0)
|
||||
bom_item = create_item(item_code="BOM Item")
|
||||
rm_item = create_item(item_code="RM Item")
|
||||
|
||||
fg_warehouse = "_Test FG Warehouse - _TC"
|
||||
|
||||
# Create warehouse if it doesn't exist
|
||||
if not frappe.db.exists("Warehouse", fg_warehouse):
|
||||
create_warehouse(warehouse_name="_Test FG Warehouse")
|
||||
|
||||
# 2. Create initial stock for components
|
||||
make_stock_entry(item_code=bom_item.name, target="_Test FG Warehouse - _TC", qty=15)
|
||||
make_stock_entry(item_code=rm_item.name, target="Stores - _TC", qty=25)
|
||||
|
||||
# 3. Create BOM for manufactured item
|
||||
bom = make_bom(
|
||||
item=bom_item.name,
|
||||
raw_materials=[rm_item.name],
|
||||
set_as_default_bom=1,
|
||||
)
|
||||
|
||||
# 4. Create Product Bundle (Bundle Item → contains BOM Item)
|
||||
make_product_bundle(parent=bundle_item.name, items=[bom_item.name])
|
||||
|
||||
# 5. Create Sales Order for 50 units of Bundle Item
|
||||
sales_order = make_sales_order(item_code=bundle_item.name, qty=50, warehouse=fg_warehouse)
|
||||
|
||||
# 6. Create Work Order for partial quantity (25 out of 50)
|
||||
work_order_qty = 25
|
||||
work_order = make_wo_order_test_record(
|
||||
production_item=bom_item.name,
|
||||
bom_no=bom.name,
|
||||
qty=work_order_qty,
|
||||
sales_order=sales_order.name,
|
||||
source_warehouse="Stores - _TC",
|
||||
fg_warehouse=fg_warehouse,
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
# Link Work Order to correct Sales Order Item row
|
||||
work_order.sales_order_item = sales_order.items[0].name
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
# 7. Material transfer from Stores → WIP
|
||||
transfer_entry = frappe.get_doc(
|
||||
create_stock_entry(work_order.name, "Material Transfer for Manufacture")
|
||||
)
|
||||
for d in transfer_entry.get("items"):
|
||||
d.s_warehouse = "Stores - _TC"
|
||||
transfer_entry.insert()
|
||||
transfer_entry.submit()
|
||||
|
||||
# 8. Complete manufacturing (WIP → Finished Goods)
|
||||
manufacture_entry = frappe.get_doc(create_stock_entry(work_order.name, "Manufacture"))
|
||||
manufacture_entry.insert()
|
||||
manufacture_entry.submit()
|
||||
|
||||
# 9. Verify work order qty is correctly updated in Sales Order
|
||||
sales_order.reload()
|
||||
self.assertEqual(sales_order.items[0].work_order_qty, work_order_qty)
|
||||
|
||||
# 10. Create partial Delivery Note (40 out of 50)
|
||||
dn = make_delivery_note(sales_order.name)
|
||||
dn.items[0].qty = 40
|
||||
dn.save()
|
||||
dn.submit()
|
||||
|
||||
# 11. Check delivered quantity updated correctly
|
||||
sales_order.reload()
|
||||
self.assertEqual(sales_order.items[0].delivered_qty, 40)
|
||||
|
||||
# 12. Create Production Plan from remaining open Sales Order quantity
|
||||
pln = frappe.new_doc("Production Plan")
|
||||
pln.company = sales_order.company
|
||||
pln.get_items_from = "Sales Order"
|
||||
pln.item_code = bundle_item.name
|
||||
|
||||
# Fetch open sales orders
|
||||
pln.get_open_sales_orders()
|
||||
self.assertEqual(pln.sales_orders[0].sales_order, sales_order.name)
|
||||
|
||||
# Pull items → should plan remaining 10 qty
|
||||
pln.get_so_items()
|
||||
|
||||
"""
|
||||
Test Case: Production Plan should plan remaining 10 units
|
||||
(50 ordered - 25 manufactured - 40 delivered = 10 pending)
|
||||
"""
|
||||
self.assertEqual(pln.po_items[0].planned_qty, 10)
|
||||
|
||||
def test_multiple_work_order_for_production_plan_item(self):
|
||||
"Test producing Prod Plan (making WO) in parts."
|
||||
|
||||
|
||||
@@ -2467,6 +2467,259 @@ class TestWorkOrder(FrappeTestCase):
|
||||
f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}",
|
||||
)
|
||||
|
||||
def test_disassembly_with_multiple_manufacture_entries(self):
|
||||
"""
|
||||
Test that disassembly does not create duplicate items when manufacturing
|
||||
is done in multiple batches (multiple manufacture stock entries).
|
||||
|
||||
Scenario:
|
||||
1. Create Work Order for 10 units
|
||||
2. Transfer raw materials
|
||||
3. Manufacture in 2 parts (3 units, then 7 units) - creates 2 stock entries
|
||||
4. Create Disassembly for 4 units
|
||||
5. Verify no duplicate items in the disassembly stock entry
|
||||
"""
|
||||
# Create RM and FG item
|
||||
raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name
|
||||
raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
|
||||
|
||||
# Create WO
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
|
||||
|
||||
# Ensure enough stock
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
make_stock_entry_test_record(
|
||||
item_code=raw_item1,
|
||||
purpose="Material Receipt",
|
||||
target=wo.wip_warehouse,
|
||||
qty=50,
|
||||
basic_rate=100,
|
||||
)
|
||||
make_stock_entry_test_record(
|
||||
item_code=raw_item2,
|
||||
purpose="Material Receipt",
|
||||
target=wo.wip_warehouse,
|
||||
qty=50,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
# Transfer for manufacture
|
||||
se_for_material_transfer = frappe.get_doc(
|
||||
make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
|
||||
)
|
||||
for item in se_for_material_transfer.items:
|
||||
item.s_warehouse = wo.wip_warehouse
|
||||
se_for_material_transfer.save()
|
||||
se_for_material_transfer.submit()
|
||||
|
||||
# First Manufacture Entry - 3 units
|
||||
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
se_manufacture1.submit()
|
||||
|
||||
# Second Manufacture Entry - 7 units
|
||||
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
|
||||
se_manufacture2.submit()
|
||||
|
||||
wo.reload()
|
||||
self.assertEqual(wo.produced_qty, 10)
|
||||
|
||||
# Count manufacture entries
|
||||
manufacture_entries = frappe.get_all(
|
||||
"Stock Entry",
|
||||
filters={
|
||||
"work_order": wo.name,
|
||||
"purpose": "Manufacture",
|
||||
"docstatus": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(manufacture_entries), 2, "Expected 2 manufacture entries")
|
||||
|
||||
# Disassembly for 4 units
|
||||
disassemble_qty = 4
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
item_counts = {}
|
||||
for item in stock_entry.items:
|
||||
item_code = item.item_code
|
||||
item_counts[item_code] = item_counts.get(item_code, 0) + 1
|
||||
|
||||
# No duplicates
|
||||
duplicates = {k: v for k, v in item_counts.items() if v > 1}
|
||||
self.assertEqual(
|
||||
len(duplicates),
|
||||
0,
|
||||
f"Found duplicate items in disassembly stock entry: {duplicates}",
|
||||
)
|
||||
|
||||
expected_items = 3 # FG item + 2 raw materials
|
||||
self.assertEqual(
|
||||
len(stock_entry.items),
|
||||
expected_items,
|
||||
f"Expected {expected_items} items, found {len(stock_entry.items)}",
|
||||
)
|
||||
|
||||
# FG item qty
|
||||
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertEqual(fg_item_row.qty, disassemble_qty)
|
||||
|
||||
# RM quantities
|
||||
for bom_item in bom.items:
|
||||
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
|
||||
self.assertAlmostEqual(
|
||||
rm_row.qty,
|
||||
expected_qty,
|
||||
places=3,
|
||||
msg=f"Raw material {bom_item.item_code} qty mismatch",
|
||||
)
|
||||
|
||||
def test_disassembly_with_additional_rm_not_in_bom(self):
|
||||
"""
|
||||
Test that disassembly correctly handles additional raw materials that were
|
||||
manually added during manufacturing (not part of the BOM).
|
||||
|
||||
Scenario:
|
||||
1. Create Work Order for 10 units with 2 raw materials in BOM
|
||||
2. Transfer raw materials for manufacture
|
||||
3. Manufacture in 2 parts (3 units, then 7 units)
|
||||
4. In each manufacture entry, manually add an extra consumable item
|
||||
(not in BOM) in proportion to the manufactured qty
|
||||
5. Create Disassembly for 4 units
|
||||
6. Verify that the additional RM is included in disassembly with proportional qty
|
||||
"""
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
# Create RM and FG item
|
||||
raw_item1 = make_item("Test BOM Raw 1 for Additional RM Disassembly", {"is_stock_item": 1}).name
|
||||
raw_item2 = make_item("Test BOM Raw 2 for Additional RM Disassembly", {"is_stock_item": 1}).name
|
||||
additional_rm = make_item("Test Additional RM for Disassembly", {"is_stock_item": 1}).name
|
||||
fg_item = make_item("Test FG for Additional RM Disassembly", {"is_stock_item": 1}).name
|
||||
|
||||
bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2)
|
||||
|
||||
# Create WO
|
||||
wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started")
|
||||
|
||||
# Ensure enough stock
|
||||
for item in [raw_item1, raw_item2, additional_rm]:
|
||||
make_stock_entry_test_record(
|
||||
item_code=item,
|
||||
purpose="Material Receipt",
|
||||
target=wo.wip_warehouse,
|
||||
qty=100,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
# Transfer for manufacture
|
||||
se_for_material_transfer = frappe.get_doc(
|
||||
make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)
|
||||
)
|
||||
for item in se_for_material_transfer.items:
|
||||
item.s_warehouse = wo.wip_warehouse
|
||||
se_for_material_transfer.save()
|
||||
se_for_material_transfer.submit()
|
||||
|
||||
# First Manufacture Entry - 3 units
|
||||
se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3))
|
||||
# Additional RM
|
||||
se_manufacture1.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": additional_rm,
|
||||
"qty": 3, # 1 per unit
|
||||
"s_warehouse": wo.wip_warehouse,
|
||||
"is_finished_item": 0,
|
||||
},
|
||||
)
|
||||
se_manufacture1.save()
|
||||
se_manufacture1.submit()
|
||||
|
||||
# Second Manufacture Entry - 7 units
|
||||
se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7))
|
||||
# AAdditional RM
|
||||
se_manufacture2.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": additional_rm,
|
||||
"qty": 7, # 1 per unit
|
||||
"s_warehouse": wo.wip_warehouse,
|
||||
"is_finished_item": 0,
|
||||
},
|
||||
)
|
||||
se_manufacture2.save()
|
||||
se_manufacture2.submit()
|
||||
|
||||
wo.reload()
|
||||
self.assertEqual(wo.produced_qty, 10)
|
||||
|
||||
# Disassembly for 4 units
|
||||
disassemble_qty = 4
|
||||
stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty))
|
||||
stock_entry.save()
|
||||
stock_entry.submit()
|
||||
|
||||
# No duplicate
|
||||
item_counts = {}
|
||||
for item in stock_entry.items:
|
||||
item_code = item.item_code
|
||||
item_counts[item_code] = item_counts.get(item_code, 0) + 1
|
||||
|
||||
duplicates = {k: v for k, v in item_counts.items() if v > 1}
|
||||
self.assertEqual(
|
||||
len(duplicates),
|
||||
0,
|
||||
f"Found duplicate items in disassembly stock entry: {duplicates}",
|
||||
)
|
||||
|
||||
# Additional RM qty
|
||||
additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None)
|
||||
self.assertIsNotNone(
|
||||
additional_rm_row,
|
||||
f"Additional raw material {additional_rm} not found in disassembly",
|
||||
)
|
||||
|
||||
# intentional full reversal as not part of BOM
|
||||
# eg: dies or consumables used during manufacturing
|
||||
expected_additional_rm_qty = 3 + 7
|
||||
self.assertAlmostEqual(
|
||||
additional_rm_row.qty,
|
||||
expected_additional_rm_qty,
|
||||
places=3,
|
||||
msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}",
|
||||
)
|
||||
|
||||
# RM qty
|
||||
for bom_item in bom.items:
|
||||
expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty
|
||||
rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None)
|
||||
self.assertIsNotNone(rm_row, f"BOM raw material {bom_item.item_code} not found")
|
||||
self.assertAlmostEqual(
|
||||
rm_row.qty,
|
||||
expected_qty,
|
||||
places=3,
|
||||
msg=f"BOM raw material {bom_item.item_code} qty mismatch",
|
||||
)
|
||||
|
||||
# FG qty
|
||||
fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None)
|
||||
self.assertEqual(fg_item_row.qty, disassemble_qty)
|
||||
|
||||
expected_items = 4
|
||||
self.assertEqual(
|
||||
len(stock_entry.items),
|
||||
expected_items,
|
||||
f"Expected {expected_items} items, found {len(stock_entry.items)}",
|
||||
)
|
||||
|
||||
def test_components_alternate_item_for_bom_based_manufacture_entry(self):
|
||||
frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM")
|
||||
frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1)
|
||||
@@ -2933,6 +3186,53 @@ class TestWorkOrder(FrappeTestCase):
|
||||
|
||||
allow_overproduction("overproduction_percentage_for_work_order", 0)
|
||||
|
||||
def test_reserved_qty_for_pp_with_extra_material_transfer(self):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
make_stock_entry as make_stock_entry_test_record,
|
||||
)
|
||||
|
||||
rm_item_code = make_item(
|
||||
"_Test Reserved Qty PP Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
).name
|
||||
|
||||
fg_item_code = make_item(
|
||||
"_Test Reserved Qty PP FG Item",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
},
|
||||
).name
|
||||
|
||||
make_stock_entry_test_record(
|
||||
item_code=rm_item_code, target="_Test Warehouse - _TC", qty=10, basic_rate=100
|
||||
)
|
||||
|
||||
make_bom(
|
||||
item=fg_item_code,
|
||||
raw_materials=[rm_item_code],
|
||||
)
|
||||
|
||||
wo_order = make_wo_order_test_record(
|
||||
item=fg_item_code,
|
||||
qty=1,
|
||||
source_warehouse="_Test Warehouse - _TC",
|
||||
skip_transfer=0,
|
||||
target_warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
|
||||
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
|
||||
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 1)
|
||||
|
||||
s = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 1))
|
||||
s.items[0].qty += 2 # extra material transfer
|
||||
s.submit()
|
||||
|
||||
bin1_at_completion = get_bin(rm_item_code, "_Test Warehouse - _TC")
|
||||
|
||||
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0)
|
||||
|
||||
|
||||
def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
|
||||
@@ -209,7 +209,7 @@ frappe.ui.form.on("Work Order", {
|
||||
if (not_completed && not_completed.length) {
|
||||
frm.add_custom_button(__("Create Job Card"), () => {
|
||||
frm.trigger("make_job_card");
|
||||
}).addClass("btn-primary");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,8 @@ frappe.ui.form.on("Work Order", {
|
||||
if (
|
||||
frm.doc.docstatus === 1 &&
|
||||
["Closed", "Completed"].includes(frm.doc.status) &&
|
||||
frm.doc.produced_qty > 0
|
||||
frm.doc.produced_qty > 0 &&
|
||||
frm.doc.produced_qty > frm.doc.disassembled_qty
|
||||
) {
|
||||
frm.add_custom_button(
|
||||
__("Disassemble Order"),
|
||||
@@ -253,7 +254,7 @@ frappe.ui.form.on("Work Order", {
|
||||
if (non_consumed_items && non_consumed_items.length) {
|
||||
frm.add_custom_button(__("Return Components"), function () {
|
||||
frm.trigger("create_stock_return_entry");
|
||||
}).addClass("btn-primary");
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -402,11 +403,14 @@ frappe.ui.form.on("Work Order", {
|
||||
erpnext.work_order
|
||||
.show_prompt_for_qty_input(frm, "Disassemble")
|
||||
.then((data) => {
|
||||
if (flt(data.qty) <= 0) {
|
||||
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to <b>0</b>."));
|
||||
return;
|
||||
}
|
||||
return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", {
|
||||
work_order_id: frm.doc.name,
|
||||
purpose: "Disassemble",
|
||||
qty: data.qty,
|
||||
target_warehouse: data.target_warehouse,
|
||||
});
|
||||
})
|
||||
.then((stock_entry) => {
|
||||
@@ -863,24 +867,6 @@ erpnext.work_order = {
|
||||
},
|
||||
];
|
||||
|
||||
if (purpose === "Disassemble") {
|
||||
fields.push({
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
fieldname: "target_warehouse",
|
||||
label: __("Target Warehouse"),
|
||||
default: frm.doc.source_warehouse || frm.doc.wip_warehouse,
|
||||
get_query() {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
frm.qty_prompt = frappe.prompt(
|
||||
fields,
|
||||
|
||||
@@ -315,8 +315,8 @@ class WorkOrder(Document):
|
||||
def validate_work_order_against_so(self):
|
||||
# already ordered qty
|
||||
ordered_qty_against_so = frappe.db.sql(
|
||||
"""select sum(qty) from `tabWork Order`
|
||||
where production_item = %s and sales_order = %s and docstatus < 2 and name != %s""",
|
||||
"""select sum(qty - process_loss_qty) from `tabWork Order`
|
||||
where production_item = %s and sales_order = %s and docstatus < 2 and status != 'Closed' and name != %s""",
|
||||
(self.production_item, self.sales_order, self.name),
|
||||
)[0][0]
|
||||
|
||||
@@ -351,15 +351,16 @@ class WorkOrder(Document):
|
||||
|
||||
def update_status(self, status=None):
|
||||
"""Update status of work order if unknown"""
|
||||
if status != "Stopped" and status != "Closed":
|
||||
status = self.get_status(status)
|
||||
if self.status != "Closed":
|
||||
if status not in ["Stopped", "Closed"]:
|
||||
status = self.get_status(status)
|
||||
|
||||
if status != self.status:
|
||||
self.db_set("status", status)
|
||||
if status != self.status:
|
||||
self.db_set("status", status)
|
||||
|
||||
self.update_required_items()
|
||||
self.update_required_items()
|
||||
|
||||
return status
|
||||
return status or self.status
|
||||
|
||||
def get_status(self, status=None):
|
||||
"""Return the status based on stock entries against this work order"""
|
||||
@@ -515,6 +516,10 @@ class WorkOrder(Document):
|
||||
self.validate_cancel()
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
self.on_close_or_cancel()
|
||||
self.delete_job_card()
|
||||
|
||||
def on_close_or_cancel(self):
|
||||
if self.production_plan and frappe.db.exists(
|
||||
"Production Plan Item Reference", {"parent": self.production_plan}
|
||||
):
|
||||
@@ -522,7 +527,6 @@ class WorkOrder(Document):
|
||||
else:
|
||||
self.update_work_order_qty_in_so()
|
||||
|
||||
self.delete_job_card()
|
||||
self.update_completed_qty_in_material_request()
|
||||
self.update_planned_qty()
|
||||
self.update_ordered_qty()
|
||||
@@ -842,7 +846,7 @@ class WorkOrder(Document):
|
||||
|
||||
qty = frappe.db.sql(
|
||||
f""" select sum(qty) from
|
||||
`tabWork Order` where sales_order = %s and docstatus = 1 and {cond}
|
||||
`tabWork Order` where sales_order = %s and docstatus = 1 and status <> 'Closed' and {cond}
|
||||
""",
|
||||
(self.sales_order, (self.product_bundle_item or self.production_item)),
|
||||
as_list=1,
|
||||
@@ -979,14 +983,14 @@ class WorkOrder(Document):
|
||||
|
||||
for d in self.get("operations"):
|
||||
precision = d.precision("completed_qty")
|
||||
qty = flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision)
|
||||
qty = flt(flt(d.completed_qty, precision) + flt(d.process_loss_qty, precision), precision)
|
||||
if not qty:
|
||||
d.status = "Pending"
|
||||
elif flt(qty) < flt(self.qty):
|
||||
elif qty < flt(self.qty, precision):
|
||||
d.status = "Work in Progress"
|
||||
elif flt(qty) == flt(self.qty):
|
||||
elif qty == flt(self.qty, precision):
|
||||
d.status = "Completed"
|
||||
elif flt(qty) <= max_allowed_qty_for_wo:
|
||||
elif qty <= flt(max_allowed_qty_for_wo, precision):
|
||||
d.status = "Completed"
|
||||
else:
|
||||
frappe.throw(_("Completed Qty cannot be greater than 'Qty to Manufacture'"))
|
||||
@@ -1373,6 +1377,13 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_m
|
||||
|
||||
item_details = get_item_details(item, project)
|
||||
|
||||
if frappe.db.get_value("Item", item, "variant_of"):
|
||||
if variant_bom := frappe.db.get_value(
|
||||
"BOM",
|
||||
{"item": item, "is_default": 1, "docstatus": 1},
|
||||
):
|
||||
bom_no = variant_bom
|
||||
|
||||
wo_doc = frappe.new_doc("Work Order")
|
||||
wo_doc.production_item = item
|
||||
wo_doc.update(item_details)
|
||||
@@ -1502,7 +1513,7 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None):
|
||||
stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
stock_entry.get_items(qty, work_order.production_item)
|
||||
stock_entry.get_items()
|
||||
|
||||
if purpose != "Disassemble":
|
||||
stock_entry.set_serial_no_batch_for_finished_good()
|
||||
@@ -1596,8 +1607,8 @@ def close_work_order(work_order, status):
|
||||
)
|
||||
)
|
||||
|
||||
work_order.on_close_or_cancel()
|
||||
work_order.update_status(status)
|
||||
work_order.update_planned_qty()
|
||||
frappe.msgprint(_("Work Order has been {0}").format(status))
|
||||
work_order.notify_update()
|
||||
return work_order.status
|
||||
@@ -1775,6 +1786,9 @@ def get_reserved_qty_for_production(
|
||||
qty_field = wo_item.required_qty
|
||||
else:
|
||||
qty_field = Case()
|
||||
qty_field = qty_field.when(
|
||||
((wo.skip_transfer == 0) & (wo_item.transferred_qty > wo_item.required_qty)), 0.0
|
||||
)
|
||||
qty_field = qty_field.when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
|
||||
qty_field = qty_field.else_(wo_item.required_qty - wo_item.consumed_qty)
|
||||
|
||||
|
||||
@@ -426,4 +426,5 @@ erpnext.patches.v15_0.set_asset_status_if_not_already_set
|
||||
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_party_balance", 1)
|
||||
execute:frappe.db.set_single_value("Accounts Settings", "show_account_balance", 1)
|
||||
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter
|
||||
erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12-11
|
||||
erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges
|
||||
|
||||
@@ -2,6 +2,15 @@ import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
try:
|
||||
from erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter import execute
|
||||
|
||||
execute()
|
||||
except ImportError:
|
||||
update_frankfurter_app_parameter_and_result()
|
||||
|
||||
|
||||
def update_frankfurter_app_parameter_and_result():
|
||||
settings = frappe.get_doc("Currency Exchange Settings")
|
||||
if settings.service_provider != "frankfurter.app":
|
||||
return
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
create_accounting_dimensions_for_doctype,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
create_accounting_dimensions_for_doctype(doctype="Advance Taxes and Charges")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user