mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-20 05:04:03 +00:00
Compare commits
659 Commits
assets-dev
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4479d7ff18 | ||
|
|
5b8ba4bd52 | ||
|
|
7f81ffca23 | ||
|
|
28f6994520 | ||
|
|
6c96606c18 | ||
|
|
ff4adce91b | ||
|
|
48bbf66422 | ||
|
|
f9732efb23 | ||
|
|
3e801a2067 | ||
|
|
d61720c3e2 | ||
|
|
d955122c88 | ||
|
|
b0d9208561 | ||
|
|
f03a81b943 | ||
|
|
497a0abb07 | ||
|
|
884f57d5f6 | ||
|
|
9dcd561778 | ||
|
|
a5f21331a4 | ||
|
|
be21f56771 | ||
|
|
fbcec6e75f | ||
|
|
5fcaa54f04 | ||
|
|
c18ca7af22 | ||
|
|
1cfae33fb0 | ||
|
|
5548c3a713 | ||
|
|
928bbf22d2 | ||
|
|
57e44b3a5f | ||
|
|
a01da137ba | ||
|
|
09f03e34d0 | ||
|
|
afbaaafd00 | ||
|
|
71a07ee7af | ||
|
|
8134199a57 | ||
|
|
8479a8b4d3 | ||
|
|
9a612d0164 | ||
|
|
3a6b32bcf9 | ||
|
|
81dea34dd3 | ||
|
|
be05e01bd7 | ||
|
|
61927b61fe | ||
|
|
f8550838a3 | ||
|
|
9e15e52847 | ||
|
|
a954539b53 | ||
|
|
f8120d1818 | ||
|
|
d5d2e3406b | ||
|
|
a80be19081 | ||
|
|
9ce1b02e6e | ||
|
|
f4d9869d7b | ||
|
|
6b1e339ed4 | ||
|
|
fe13c0709b | ||
|
|
c86aa3e3ad | ||
|
|
60e05bdaa6 | ||
|
|
4f42f52306 | ||
|
|
e85f2c4fbc | ||
|
|
bbc684aa80 | ||
|
|
cb97c3a55a | ||
|
|
cb6fc640ce | ||
|
|
3d44b4d98c | ||
|
|
dd7891e18f | ||
|
|
ea665d1a9b | ||
|
|
6255495cc4 | ||
|
|
8c1a1aafe6 | ||
|
|
0a9aa448c1 | ||
|
|
02f7cba20a | ||
|
|
96d4c48357 | ||
|
|
db2e2105ab | ||
|
|
e2fbc48b9a | ||
|
|
4180e29af4 | ||
|
|
39eb34f333 | ||
|
|
c6f9415e9d | ||
|
|
f768778d81 | ||
|
|
c541bc9239 | ||
|
|
817c5007d9 | ||
|
|
900c71840c | ||
|
|
dfd0c85ba4 | ||
|
|
8caaac96b6 | ||
|
|
9f02c47592 | ||
|
|
7f47c218ce | ||
|
|
ae11b3b848 | ||
|
|
64e177df8b | ||
|
|
413ec60a3e | ||
|
|
6733681e93 | ||
|
|
5104007d12 | ||
|
|
4bc3420b21 | ||
|
|
fc9608d14d | ||
|
|
facb27c3f4 | ||
|
|
68a1fe1480 | ||
|
|
e34a64ecee | ||
|
|
060cd9f320 | ||
|
|
eb6530208b | ||
|
|
98e012095a | ||
|
|
e8bebba915 | ||
|
|
d3c0d9b283 | ||
|
|
1cfb41e1c4 | ||
|
|
0e244dd83a | ||
|
|
4c29d5630d | ||
|
|
4806b82add | ||
|
|
5a80278d1e | ||
|
|
c4e1fe274b | ||
|
|
1a56f3b032 | ||
|
|
08375a9e2f | ||
|
|
fa378e2d7a | ||
|
|
006a65e873 | ||
|
|
e7b135b51e | ||
|
|
dabc94ed06 | ||
|
|
1f4702bde7 | ||
|
|
4708ac4e3d | ||
|
|
996a02180b | ||
|
|
d8a2f53a29 | ||
|
|
13d06e77b4 | ||
|
|
5787951ed1 | ||
|
|
3f6f3abf69 | ||
|
|
055c58364a | ||
|
|
cfa6d286ad | ||
|
|
b9b402f2ec | ||
|
|
b04a9e25ff | ||
|
|
b1c6666d02 | ||
|
|
fe0465f16e | ||
|
|
3ba8f690a4 | ||
|
|
47a9c54b70 | ||
|
|
336307f287 | ||
|
|
79421bcfcc | ||
|
|
e23a7883f3 | ||
|
|
deff5848ed | ||
|
|
b579dbc1e6 | ||
|
|
41da9eb7fc | ||
|
|
1cc98a82ba | ||
|
|
eb7f7f2124 | ||
|
|
fcd312f205 | ||
|
|
3d4b50d37d | ||
|
|
5de87f473e | ||
|
|
31849f6029 | ||
|
|
1f06f2e3a0 | ||
|
|
3038ad8abe | ||
|
|
dc202ac4a2 | ||
|
|
ca07982ee0 | ||
|
|
f269f6a8d8 | ||
|
|
2ca1bdd8a7 | ||
|
|
cf338bb757 | ||
|
|
bda7a8ced2 | ||
|
|
d37e5cd97d | ||
|
|
e57593fcf8 | ||
|
|
c5b4a742b3 | ||
|
|
526f91f6b5 | ||
|
|
4465ebaeb5 | ||
|
|
88cb132fd1 | ||
|
|
d23677636d | ||
|
|
8e0ba50c4d | ||
|
|
08abf96047 | ||
|
|
813b42d706 | ||
|
|
8ce63dac65 | ||
|
|
8e9680afce | ||
|
|
a09e875109 | ||
|
|
42c61915c4 | ||
|
|
37a6ebd431 | ||
|
|
65d9f78409 | ||
|
|
acda04a4bd | ||
|
|
d1e167815f | ||
|
|
588dfac4cd | ||
|
|
ac26c01e52 | ||
|
|
c0d2bd7bce | ||
|
|
4d03e915f7 | ||
|
|
09beed9cc3 | ||
|
|
279c8dea06 | ||
|
|
0737a4cfbb | ||
|
|
1332ad7583 | ||
|
|
058be399c3 | ||
|
|
e482c846c8 | ||
|
|
6a60f072a8 | ||
|
|
18c1f0f04d | ||
|
|
217c107549 | ||
|
|
44ca5878b8 | ||
|
|
66e82c56b1 | ||
|
|
65c0d35f2e | ||
|
|
e9eca10927 | ||
|
|
6849d292f8 | ||
|
|
261b7fe7aa | ||
|
|
03be975f26 | ||
|
|
70086f92f5 | ||
|
|
e602cad39a | ||
|
|
60f528b531 | ||
|
|
30568d36d0 | ||
|
|
8527e78820 | ||
|
|
ae4a5e82b0 | ||
|
|
196fce9792 | ||
|
|
449004d29a | ||
|
|
e00cfc7c2a | ||
|
|
bae3668bd0 | ||
|
|
8ce0e5386a | ||
|
|
4ba042c7c7 | ||
|
|
59ad76c21e | ||
|
|
1efe0be379 | ||
|
|
6bb7fa6d68 | ||
|
|
ef5feb613a | ||
|
|
2fa9d7cee6 | ||
|
|
431dc208b3 | ||
|
|
0b795a628f | ||
|
|
595a4c8517 | ||
|
|
9ec224c3fd | ||
|
|
68415c341b | ||
|
|
a43df3278f | ||
|
|
4d06b01abf | ||
|
|
a04d54b2fb | ||
|
|
0476f318e4 | ||
|
|
7fbfa35f95 | ||
|
|
bbf506e848 | ||
|
|
725fd8ca97 | ||
|
|
85191d1cac | ||
|
|
93021a9d45 | ||
|
|
0afc6dd363 | ||
|
|
6dc2e43dd6 | ||
|
|
d34e4b8783 | ||
|
|
501acd0414 | ||
|
|
113943f851 | ||
|
|
c124e90a89 | ||
|
|
9096b4a9df | ||
|
|
e69eaa5102 | ||
|
|
e77b27ae99 | ||
|
|
60235f4b2b | ||
|
|
463103ebf1 | ||
|
|
a3ec98a57c | ||
|
|
9ab8803fed | ||
|
|
8a566e6ba5 | ||
|
|
812a06cf44 | ||
|
|
23778c3875 | ||
|
|
24a66d10e7 | ||
|
|
3faaa87645 | ||
|
|
afeaba5142 | ||
|
|
1a016cbcd6 | ||
|
|
7532ec9f9a | ||
|
|
48e66d04e6 | ||
|
|
b1b6ae98ed | ||
|
|
59a69fc497 | ||
|
|
a899183087 | ||
|
|
3d109571ee | ||
|
|
d079677500 | ||
|
|
6e62750c2f | ||
|
|
faadc1620b | ||
|
|
f249d57b30 | ||
|
|
3f66541b99 | ||
|
|
e7c2f8ee11 | ||
|
|
8dacf62da0 | ||
|
|
935746e752 | ||
|
|
4e2a10e496 | ||
|
|
a32c784084 | ||
|
|
2d24eedab2 | ||
|
|
ef1fbb7899 | ||
|
|
b5ecc9e6bd | ||
|
|
f195044fd1 | ||
|
|
004087097c | ||
|
|
992015424b | ||
|
|
a86b169d8b | ||
|
|
e183e32619 | ||
|
|
28992eb2f4 | ||
|
|
0ae61c4921 | ||
|
|
8190696d36 | ||
|
|
b12032485b | ||
|
|
be0f571d62 | ||
|
|
3a1e4d14f3 | ||
|
|
1fda0dfb9b | ||
|
|
1b4487450c | ||
|
|
9564f677e4 | ||
|
|
62f6d18143 | ||
|
|
1dbdf85ddc | ||
|
|
026ec8a6d9 | ||
|
|
a9207f1e12 | ||
|
|
2a5ba9050e | ||
|
|
02d41b1dac | ||
|
|
501c8087cb | ||
|
|
13e1f84eb1 | ||
|
|
75394baa28 | ||
|
|
fb59f825ee | ||
|
|
34f78f7261 | ||
|
|
987f606b4d | ||
|
|
b1b510c824 | ||
|
|
066158174e | ||
|
|
07d073da0d | ||
|
|
ba1b1ee20d | ||
|
|
213adc9ebe | ||
|
|
0e7d45b1af | ||
|
|
7e602d5389 | ||
|
|
529f8dc7cd | ||
|
|
609ccc3cb1 | ||
|
|
dceb9a3c6c | ||
|
|
52b406f5f1 | ||
|
|
3dda2005d8 | ||
|
|
322d4dff25 | ||
|
|
01a10fb5b0 | ||
|
|
4c084f7eff | ||
|
|
627f2058b5 | ||
|
|
8db4d2705a | ||
|
|
1a8d73cbbe | ||
|
|
4ca7bc8ccf | ||
|
|
40942401df | ||
|
|
ca5cc4afdc | ||
|
|
380b005659 | ||
|
|
df0ad93262 | ||
|
|
f503614cc0 | ||
|
|
6d9beea56b | ||
|
|
560d8bb674 | ||
|
|
e91bcd6dd6 | ||
|
|
a3e3e1b32c | ||
|
|
2492dfa558 | ||
|
|
3b5a203d61 | ||
|
|
934abe5c6d | ||
|
|
867ee484b9 | ||
|
|
c1bef53f92 | ||
|
|
2652082475 | ||
|
|
35e55d3e13 | ||
|
|
abb579e2db | ||
|
|
0c2d5488a6 | ||
|
|
138f683a68 | ||
|
|
479f9f63c9 | ||
|
|
56bfe6b6a6 | ||
|
|
acae34c8e1 | ||
|
|
dcbe4a6d55 | ||
|
|
87d26a2d67 | ||
|
|
e1d8d06966 | ||
|
|
8c88cecc1f | ||
|
|
9aeafb8140 | ||
|
|
6f9a8ff101 | ||
|
|
8e627db785 | ||
|
|
b2eb6a69c1 | ||
|
|
986af3852c | ||
|
|
c24e9796ae | ||
|
|
c7d42e161b | ||
|
|
701896692a | ||
|
|
93d6be2ed7 | ||
|
|
b0e9ad198f | ||
|
|
a9029f83c7 | ||
|
|
31e4da562d | ||
|
|
e6fdb3702a | ||
|
|
bd60a9be90 | ||
|
|
a64466561f | ||
|
|
f7ff25d9a8 | ||
|
|
021b807057 | ||
|
|
c933e34914 | ||
|
|
87092961e7 | ||
|
|
bc7c0de208 | ||
|
|
3f436985ed | ||
|
|
de3df6bcef | ||
|
|
cf127e8900 | ||
|
|
6771daf6a1 | ||
|
|
9ea766fc10 | ||
|
|
2d93c5835a | ||
|
|
53180fde93 | ||
|
|
224dff32df | ||
|
|
292bfa2a34 | ||
|
|
e90896ced7 | ||
|
|
c360487cd1 | ||
|
|
a0177fdbe8 | ||
|
|
64175bdb3e | ||
|
|
4fed04c6c7 | ||
|
|
35fe9c60c7 | ||
|
|
878c22fa3f | ||
|
|
12ada21639 | ||
|
|
daf3f2e142 | ||
|
|
d0f1239d2b | ||
|
|
ea3ec325e2 | ||
|
|
73d1852773 | ||
|
|
9c5f9218b5 | ||
|
|
a8a78a2163 | ||
|
|
0b6121422d | ||
|
|
9249fa89aa | ||
|
|
5a816d19cb | ||
|
|
a7d41f24a3 | ||
|
|
81a1c2c8ce | ||
|
|
0c6f7fed55 | ||
|
|
bfee9df9aa | ||
|
|
288f36bbd7 | ||
|
|
a3c9072812 | ||
|
|
bddd1d0ebc | ||
|
|
aa9f225c41 | ||
|
|
9c799f31ff | ||
|
|
a60afaf91a | ||
|
|
a4cff805f1 | ||
|
|
4f55071eda | ||
|
|
43bb6c5a42 | ||
|
|
34955380ee | ||
|
|
1714e13b39 | ||
|
|
263c3e9dd4 | ||
|
|
c97c2d1e02 | ||
|
|
cf37478870 | ||
|
|
060a5c4eeb | ||
|
|
3ad32f4030 | ||
|
|
dfc824ded6 | ||
|
|
f099dbad35 | ||
|
|
cc8ce03232 | ||
|
|
bcc1e73962 | ||
|
|
32d7250946 | ||
|
|
4c1cabb53e | ||
|
|
1105cb8ddf | ||
|
|
8bb4ffc6b1 | ||
|
|
dfd7cd0bae | ||
|
|
e083aa4c86 | ||
|
|
c4fbc745db | ||
|
|
2b6234f7af | ||
|
|
88b9911136 | ||
|
|
360f52e636 | ||
|
|
6201fefdfb | ||
|
|
08129ff71c | ||
|
|
5357634b70 | ||
|
|
20ba97aa7d | ||
|
|
d90d4c29e1 | ||
|
|
ddbd61b2a2 | ||
|
|
6a7c9f616e | ||
|
|
a3194720b4 | ||
|
|
7825ddf989 | ||
|
|
e9b67ff682 | ||
|
|
4c3aa9b4f3 | ||
|
|
ca77145522 | ||
|
|
5753c23ccf | ||
|
|
a397e82278 | ||
|
|
9c23229cbf | ||
|
|
08f6af867a | ||
|
|
6988781f81 | ||
|
|
49093b326e | ||
|
|
9503dd0c7f | ||
|
|
bd0acf4413 | ||
|
|
969cdf1b26 | ||
|
|
8db1eb0d27 | ||
|
|
d146dc5435 | ||
|
|
0ca38517f3 | ||
|
|
5d1af7fc93 | ||
|
|
1fab935434 | ||
|
|
d6ba0f0eca | ||
|
|
49164f41b1 | ||
|
|
e36426e235 | ||
|
|
ba936eefab | ||
|
|
5eb9461cfd | ||
|
|
e1e588e416 | ||
|
|
00880eb657 | ||
|
|
ae6aef91bd | ||
|
|
faf92b1368 | ||
|
|
a52c8fdaea | ||
|
|
030e1a77e6 | ||
|
|
d2306b1b29 | ||
|
|
601f39dda7 | ||
|
|
047e4faa90 | ||
|
|
8d7edafc99 | ||
|
|
8f15dd4d5d | ||
|
|
bf769a52c0 | ||
|
|
1e238678d8 | ||
|
|
bb36e956ac | ||
|
|
5641f37381 | ||
|
|
577a79471b | ||
|
|
c2e472b03c | ||
|
|
e5f9698055 | ||
|
|
e45b027a22 | ||
|
|
78cc06f127 | ||
|
|
00646b7ed3 | ||
|
|
58582cfa09 | ||
|
|
9267bd9eea | ||
|
|
298d3d9016 | ||
|
|
a9f0ec83a4 | ||
|
|
1ef4978a86 | ||
|
|
f33de37da0 | ||
|
|
2a6d9be18a | ||
|
|
d1765e85aa | ||
|
|
3df8e7bfe6 | ||
|
|
f7460f7be3 | ||
|
|
920abdc0e2 | ||
|
|
e0e3dcc8bf | ||
|
|
9d020365e0 | ||
|
|
0f876c10aa | ||
|
|
7f3ddfb3a1 | ||
|
|
268d98d5f7 | ||
|
|
1be84112a7 | ||
|
|
fcff212eec | ||
|
|
9b1157c914 | ||
|
|
0ba2961103 | ||
|
|
37d2adc74b | ||
|
|
859d4caae4 | ||
|
|
3a50056968 | ||
|
|
e1f6bb70bc | ||
|
|
734fe874f2 | ||
|
|
5aab5502f0 | ||
|
|
5873f55cf0 | ||
|
|
df03524b19 | ||
|
|
18dbc7887b | ||
|
|
7c6b13a838 | ||
|
|
7d72d21bbe | ||
|
|
62fdc4c457 | ||
|
|
b41eb6876a | ||
|
|
9bb71e5ec4 | ||
|
|
c5ff1009b2 | ||
|
|
ff2b9a99e7 | ||
|
|
b82b2c2ebd | ||
|
|
5dbf3fdde0 | ||
|
|
4b0b7adeee | ||
|
|
8db05fc4da | ||
|
|
6a064765d1 | ||
|
|
78d5fbaca4 | ||
|
|
3dba21f814 | ||
|
|
f4705fd5a8 | ||
|
|
f1f66bdf2f | ||
|
|
a02ef40a5b | ||
|
|
1a4b61a822 | ||
|
|
34a0aa2ee9 | ||
|
|
e2a1f6057d | ||
|
|
34d128d752 | ||
|
|
d6a201ed4a | ||
|
|
0a07fb3a4e | ||
|
|
9cecf2e6f9 | ||
|
|
d1fd91a542 | ||
|
|
8e41e75d89 | ||
|
|
7c2406077a | ||
|
|
926bdf5a20 | ||
|
|
b447cbc3c1 | ||
|
|
4affdd51f6 | ||
|
|
a26d8d448c | ||
|
|
8de259a669 | ||
|
|
2ecf8b0466 | ||
|
|
700a7fdad3 | ||
|
|
ca310693ff | ||
|
|
e842812ba5 | ||
|
|
5289752c5f | ||
|
|
3757544359 | ||
|
|
51fee2d602 | ||
|
|
d54db2e0ca | ||
|
|
cb84678198 | ||
|
|
40bcf6e3b6 | ||
|
|
3294490040 | ||
|
|
855eeb1078 | ||
|
|
ef8cc166c1 | ||
|
|
3c5cb8d579 | ||
|
|
5adeca44da | ||
|
|
371b5c7593 | ||
|
|
c271826130 | ||
|
|
4c6f33000b | ||
|
|
635d291b62 | ||
|
|
092d8f771c | ||
|
|
4ee8bbb06b | ||
|
|
53dfef8030 | ||
|
|
d2d28c9e03 | ||
|
|
8b916b40ee | ||
|
|
bca917380d | ||
|
|
64a3be8163 | ||
|
|
3337b47182 | ||
|
|
dfe3280737 | ||
|
|
8a8b89e5dd | ||
|
|
a75693a81f | ||
|
|
d0d9411700 | ||
|
|
c4d28a2612 | ||
|
|
6c46692cc4 | ||
|
|
68b8ba7235 | ||
|
|
e0c285e27e | ||
|
|
b72cde73ba | ||
|
|
260cec3b86 | ||
|
|
cfed16ab6c | ||
|
|
d8760b76a8 | ||
|
|
0b4e20ae98 | ||
|
|
a2a2e1020b | ||
|
|
86726bbd85 | ||
|
|
8164782263 | ||
|
|
0c61ad4e6d | ||
|
|
5074597d00 | ||
|
|
42383c3f36 | ||
|
|
3b2f2168d0 | ||
|
|
36dc196a1d | ||
|
|
04443ae29e | ||
|
|
da82ac86b5 | ||
|
|
efb8336bf8 | ||
|
|
b1882dc83a | ||
|
|
41884cfd2a | ||
|
|
48700a8aa3 | ||
|
|
c34eeee096 | ||
|
|
016b64df6d | ||
|
|
cd7fa56ec4 | ||
|
|
e94bd51764 | ||
|
|
e1ea14b135 | ||
|
|
7afe5d4ee3 | ||
|
|
d154796c82 | ||
|
|
d6f9e4ac3f | ||
|
|
10c18ca801 | ||
|
|
0a49403838 | ||
|
|
f0ba54d957 | ||
|
|
7ee7c4253b | ||
|
|
519dc0b958 | ||
|
|
85be72a403 | ||
|
|
78f9434d14 | ||
|
|
530e587bf2 | ||
|
|
c68918bc18 | ||
|
|
e8fff2fdad | ||
|
|
e460e83516 | ||
|
|
498cd2b371 | ||
|
|
9084570d18 | ||
|
|
c324c823fb | ||
|
|
516406c25b | ||
|
|
61da2302ba | ||
|
|
35ac7155e8 | ||
|
|
28c3d24b86 | ||
|
|
9b85773757 | ||
|
|
341fad04c9 | ||
|
|
0a4fa5e35e | ||
|
|
f9d67ebb1e | ||
|
|
7b456c6405 | ||
|
|
92983255b3 | ||
|
|
7b9f61e058 | ||
|
|
0968adafc8 | ||
|
|
220b6fe572 | ||
|
|
8192d70f83 | ||
|
|
2cf51a0367 | ||
|
|
01e7224210 | ||
|
|
18d1a88a64 | ||
|
|
cfd37f22db | ||
|
|
cfff10463c | ||
|
|
25e3d6042a | ||
|
|
0a02727638 | ||
|
|
a12d666037 | ||
|
|
c7b4806117 | ||
|
|
6c1ac51d7a | ||
|
|
8aaa3a72ef | ||
|
|
2c0f6c50df | ||
|
|
0ee0d6f0c5 | ||
|
|
bb803a8f82 | ||
|
|
983d80f7c5 | ||
|
|
cba6a31497 | ||
|
|
9ad046109c | ||
|
|
29261c5fc2 | ||
|
|
58c90ad651 | ||
|
|
8783689ec5 | ||
|
|
8d3efe287e | ||
|
|
b63e1fd796 | ||
|
|
18188cb1b2 | ||
|
|
001c70831c | ||
|
|
b68daea365 | ||
|
|
e8f9cf6e3f | ||
|
|
55368256fd | ||
|
|
8f05e0596e | ||
|
|
473f6e833a | ||
|
|
d775d540c4 | ||
|
|
b381061742 | ||
|
|
90801550eb | ||
|
|
8677e2df40 | ||
|
|
9c78c9ab7b | ||
|
|
32c4b1d98a | ||
|
|
6467f07459 | ||
|
|
b5c96dfef0 | ||
|
|
cf1817c1ea | ||
|
|
3ec6387425 | ||
|
|
234c4a45b8 | ||
|
|
064340cafb | ||
|
|
dfbd8db9d3 | ||
|
|
58f24c83c0 | ||
|
|
b1de654dfd | ||
|
|
d57786caa2 | ||
|
|
a2f877cee6 | ||
|
|
814c11200a | ||
|
|
f7c744350c | ||
|
|
cf597361f6 | ||
|
|
88f6f182e3 | ||
|
|
4c8f95a1a5 | ||
|
|
9ea56910a1 | ||
|
|
d2b09f71c3 | ||
|
|
f31b3749bc | ||
|
|
30b9e11303 | ||
|
|
4b1d369ac6 | ||
|
|
3592c3086d | ||
|
|
bdf0136fc5 | ||
|
|
7335011814 | ||
|
|
671555edbc | ||
|
|
df6fd782b7 |
51
.github/helper/install.sh
vendored
51
.github/helper/install.sh
vendored
@@ -4,24 +4,46 @@ set -e
|
|||||||
|
|
||||||
cd ~ || exit
|
cd ~ || exit
|
||||||
|
|
||||||
sudo apt update
|
|
||||||
sudo apt remove mysql-server mysql-client
|
|
||||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
|
|
||||||
|
|
||||||
pip install frappe-bench
|
|
||||||
|
|
||||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1 — parallelise the three slow, independent setup steps:
|
||||||
|
# a) system packages b) frappe-bench pip install c) frappe git fetch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
sudo apt update
|
||||||
|
|
||||||
|
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||||
|
sudo apt remove mysql-server mysql-client
|
||||||
|
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||||
|
apt_pid=$!
|
||||||
|
|
||||||
|
pip install frappe-bench &
|
||||||
|
pip_pid=$!
|
||||||
|
|
||||||
mkdir frappe
|
mkdir frappe
|
||||||
|
(
|
||||||
|
cd frappe
|
||||||
|
git init
|
||||||
|
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||||
|
git fetch origin "${frappecommitish}" --depth 1
|
||||||
|
) &
|
||||||
|
clone_pid=$!
|
||||||
|
|
||||||
|
wait $apt_pid
|
||||||
|
wait $pip_pid
|
||||||
|
wait $clone_pid
|
||||||
|
|
||||||
pushd frappe
|
pushd frappe
|
||||||
git init
|
|
||||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
|
||||||
git fetch origin "${frappecommitish}" --depth 1
|
|
||||||
git checkout FETCH_HEAD
|
git checkout FETCH_HEAD
|
||||||
popd
|
popd
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2 — bench init and site setup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||||
|
|
||||||
mkdir ~/frappe-bench/sites/test_site
|
mkdir ~/frappe-bench/sites/test_site
|
||||||
@@ -37,6 +59,11 @@ if [ "$DB" == "mariadb" ];then
|
|||||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||||
|
|
||||||
|
# Belt-and-suspenders: also set performance variables at runtime in case
|
||||||
|
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
|
||||||
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
|
||||||
|
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||||
|
|
||||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||||
@@ -51,9 +78,11 @@ fi
|
|||||||
|
|
||||||
|
|
||||||
install_whktml() {
|
install_whktml() {
|
||||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||||
|
if [ ! -f /tmp/wkhtmltox.deb ]; then
|
||||||
|
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||||
|
fi
|
||||||
sudo apt install /tmp/wkhtmltox.deb
|
sudo apt install /tmp/wkhtmltox.deb
|
||||||
|
|
||||||
}
|
}
|
||||||
install_whktml &
|
install_whktml &
|
||||||
wkpid=$!
|
wkpid=$!
|
||||||
|
|||||||
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Review translation PRs
|
||||||
|
description: "Posts review comments with relevant translation changes that are hard to inspect in the diff view."
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened, synchronize, ready_for_review]
|
||||||
|
paths:
|
||||||
|
- "**/*.po"
|
||||||
|
- "**/*.pot"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: po-review-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review-po-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: alyf-de/po-review-action@v1.0.0
|
||||||
21
.github/workflows/server-tests-mariadb.yml
vendored
21
.github/workflows/server-tests-mariadb.yml
vendored
@@ -59,6 +59,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TZ: 'Asia/Kolkata'
|
TZ: 'Asia/Kolkata'
|
||||||
MARIADB_ROOT_PASSWORD: 'root'
|
MARIADB_ROOT_PASSWORD: 'root'
|
||||||
|
# Disable durability guarantees that are unnecessary in a throwaway CI container.
|
||||||
|
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
|
||||||
|
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
|
||||||
|
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
|
||||||
ports:
|
ports:
|
||||||
- 3306:3306
|
- 3306:3306
|
||||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||||
@@ -122,6 +126,12 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-yarn-
|
${{ runner.os }}-yarn-
|
||||||
|
|
||||||
|
- name: Cache wkhtmltopdf
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/wkhtmltox.deb
|
||||||
|
key: wkhtmltox-0.12.6.1-2-jammy-amd64
|
||||||
|
|
||||||
- name: Install
|
- name: Install
|
||||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||||
env:
|
env:
|
||||||
@@ -131,7 +141,14 @@ jobs:
|
|||||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
|
run: |
|
||||||
|
cd ~/frappe-bench/
|
||||||
|
coverage_flag=""
|
||||||
|
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||||
|
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||||
|
--total-builds ${{ strategy.job-total }} \
|
||||||
|
--build-number ${{ matrix.container }} \
|
||||||
|
$coverage_flag
|
||||||
env:
|
env:
|
||||||
TYPE: server
|
TYPE: server
|
||||||
|
|
||||||
@@ -141,6 +158,7 @@ jobs:
|
|||||||
run: cat ~/frappe-bench/bench_start.log || true
|
run: cat ~/frappe-bench/bench_start.log || true
|
||||||
|
|
||||||
- name: Upload coverage data
|
- name: Upload coverage data
|
||||||
|
if: ${{ env.WITH_COVERAGE == 'true' }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ matrix.container }}
|
name: coverage-${{ matrix.container }}
|
||||||
@@ -149,6 +167,7 @@ jobs:
|
|||||||
coverage:
|
coverage:
|
||||||
name: Coverage Wrap Up
|
name: Coverage Wrap Up
|
||||||
needs: test
|
needs: test
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Clone
|
- name: Clone
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ on:
|
|||||||
- cron: "0 10 * * 1"
|
- cron: "0 10 * * 1"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# The runner dispatch uses RELEASE_TOKEN (a PAT), not the default GITHUB_TOKEN,
|
||||||
|
# so no GITHUB_TOKEN permissions are required.
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
trigger-runners:
|
trigger-runners:
|
||||||
name: Trigger sync → ${{ matrix.hotfix_branch }}
|
name: Trigger sync → ${{ matrix.hotfix_branch }}
|
||||||
|
|||||||
10
.greptile/config.json
Normal file
10
.greptile/config.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"disabledLabels": [
|
||||||
|
"conflicts"
|
||||||
|
],
|
||||||
|
"context": {
|
||||||
|
"repos": [
|
||||||
|
"frappe/frappe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"vite": "^8.0.11"
|
"vite": "^8.0.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { de
|
|||||||
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
|
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{error && <ErrorBanner error={error} />}
|
{error && <div className="py-2"><ErrorBanner error={error} /></div>}
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<CurrencyFormField
|
<CurrencyFormField
|
||||||
name="balance"
|
name="balance"
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import CSVRawDataPreview from './CSVRawDataPreview'
|
|||||||
import StatementDetails from './StatementDetails'
|
import StatementDetails from './StatementDetails'
|
||||||
import { GetStatementDetailsResponse } from '../import_utils'
|
import { GetStatementDetailsResponse } from '../import_utils'
|
||||||
|
|
||||||
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsResponse }, mutate: () => void }) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex">
|
<div className="w-full flex">
|
||||||
@@ -12,7 +10,7 @@ const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } })
|
|||||||
<StatementDetails data={data.message} />
|
<StatementDetails data={data.message} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||||
<CSVRawDataPreview data={data.message} />
|
<CSVRawDataPreview data={data.message} mutate={mutate} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,151 +1,104 @@
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { toast } from "sonner"
|
||||||
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
import { GetStatementDetailsResponse } from "../import_utils"
|
import RawTableGrid from "../RawTableGrid"
|
||||||
import { useMemo } from "react"
|
import {
|
||||||
|
applyColumnMappingChange,
|
||||||
|
ColumnMapsTo,
|
||||||
|
GetStatementDetailsResponse,
|
||||||
|
useSetHeaderIndex,
|
||||||
|
useUpdateColumnMapping,
|
||||||
|
} from "../import_utils"
|
||||||
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
|
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
|
||||||
|
|
||||||
|
type Mapping = Pick<BankStatementImportLogColumnMap, "index" | "maps_to" | "header_text" | "variable">
|
||||||
|
|
||||||
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
|
const toMapping = (columns?: BankStatementImportLogColumnMap[]): Mapping[] =>
|
||||||
|
(columns ?? []).map((c) => ({
|
||||||
|
index: c.index,
|
||||||
|
maps_to: c.maps_to,
|
||||||
|
header_text: c.header_text,
|
||||||
|
variable: c.variable,
|
||||||
|
}))
|
||||||
|
|
||||||
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
|
const headerToState = (index?: number) => (index != null && index >= 0 ? index : null)
|
||||||
|
|
||||||
const col_map: Record<string, number> = {}
|
const CSVRawDataPreview = ({
|
||||||
|
data,
|
||||||
|
mutate,
|
||||||
|
}: {
|
||||||
|
data: GetStatementDetailsResponse
|
||||||
|
mutate: () => void
|
||||||
|
}) => {
|
||||||
|
const isCompleted = data.doc.status === "Completed"
|
||||||
|
|
||||||
data.doc.column_mapping?.forEach(col => {
|
const [mapping, setMapping] = useState<Mapping[]>(() => toMapping(data.doc.column_mapping))
|
||||||
if (col.maps_to && col.maps_to !== "Do not import") {
|
const [headerIndex, setHeaderIndex] = useState<number | null>(() =>
|
||||||
col_map[col.maps_to] = col.index;
|
headerToState(data.doc.detected_header_index),
|
||||||
}
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return col_map
|
const { call: updateMapping, loading: savingMapping } = useUpdateColumnMapping()
|
||||||
|
const { call: setHeader, loading: savingHeader } = useSetHeaderIndex()
|
||||||
|
|
||||||
}, [data])
|
const mappingRef = useRef(mapping)
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
const validColumns = Object.values(column_mapping)
|
useEffect(() => () => clearTimeout(saveTimer.current), [])
|
||||||
|
|
||||||
// Reverse the column mapping to get a map of column index to variable name
|
const columnMappingRecord: Record<number, ColumnMapsTo> = {}
|
||||||
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
|
mapping.forEach((c) => {
|
||||||
|
if (c.maps_to) columnMappingRecord[c.index] = c.maps_to as ColumnMapsTo
|
||||||
|
})
|
||||||
|
|
||||||
|
const commitMapping = (next: Mapping[]) => {
|
||||||
|
mappingRef.current = next
|
||||||
|
setMapping(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist mapping edits (debounced) so the transaction preview updates in realtime.
|
||||||
|
const scheduleSaveMapping = () => {
|
||||||
|
if (isCompleted) return
|
||||||
|
clearTimeout(saveTimer.current)
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
updateMapping({ statement_import_id: data.doc.name, column_mapping: mappingRef.current })
|
||||||
|
.then(() => mutate())
|
||||||
|
.catch(() => toast.error(_("Could not save the column mapping.")))
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeMapping = (columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||||
|
if (isCompleted) return
|
||||||
|
commitMapping(applyColumnMappingChange(mappingRef.current, columnIndex, mapsTo))
|
||||||
|
scheduleSaveMapping()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSetHeader = (rowIndex: number | null) => {
|
||||||
|
if (isCompleted) return
|
||||||
|
setHeaderIndex(rowIndex)
|
||||||
|
setHeader({ statement_import_id: data.doc.name, header_index: rowIndex ?? -1 })
|
||||||
|
.then((res) => {
|
||||||
|
// The backend re-derives the mapping for the new header; sync local state.
|
||||||
|
const doc = res?.message?.doc
|
||||||
|
if (doc) {
|
||||||
|
commitMapping(toMapping(doc.column_mapping))
|
||||||
|
setHeaderIndex(headerToState(doc.detected_header_index))
|
||||||
|
}
|
||||||
|
mutate()
|
||||||
|
})
|
||||||
|
.catch(() => toast.error(_("Could not update the header row.")))
|
||||||
|
}
|
||||||
|
|
||||||
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
|
|
||||||
return (
|
return (
|
||||||
<Table containerClassName="rounded-none">
|
<RawTableGrid
|
||||||
<TableBody>
|
rows={data.raw_data}
|
||||||
{data.raw_data.map((row, index) => {
|
columnMapping={columnMappingRecord}
|
||||||
|
headerIndex={headerIndex}
|
||||||
const isHeaderRow = index === data.doc.detected_header_index;
|
editable={!isCompleted}
|
||||||
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
|
disabled={isCompleted || savingMapping || savingHeader}
|
||||||
|
onChangeMapping={onChangeMapping}
|
||||||
return <TableRow key={index}
|
onSetHeader={onSetHeader}
|
||||||
title={isHeaderRow ? "Header Row" : ""}
|
/>
|
||||||
className={cn({
|
|
||||||
// "bg-yellow-100": isHeaderRow,
|
|
||||||
// "hover:bg-yellow-100": isHeaderRow,
|
|
||||||
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
|
|
||||||
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
|
|
||||||
})}>
|
|
||||||
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
|
|
||||||
{index + 1}
|
|
||||||
</TableHead> :
|
|
||||||
<TableCell className="text-center px-1 py-0.5">
|
|
||||||
{index + 1}
|
|
||||||
</TableCell>
|
|
||||||
}
|
|
||||||
{row.map((cell, cellIndex) => {
|
|
||||||
|
|
||||||
const isValidColumn = validColumns.includes(cellIndex);
|
|
||||||
const columnType = columnIndexMap[cellIndex];
|
|
||||||
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
|
|
||||||
|
|
||||||
if (isHeaderRow) {
|
|
||||||
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
|
|
||||||
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
|
|
||||||
)}>
|
|
||||||
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
|
|
||||||
"justify-end": isAmountColumn && isValidColumn
|
|
||||||
})}>
|
|
||||||
{columnType && <Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<ColumnHeaderIcon columnType={columnType} />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{_(columnType)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
{cell}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
} else {
|
|
||||||
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
|
|
||||||
{
|
|
||||||
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
|
|
||||||
"text-ink-gray-5": !isValidColumn && isTransactionRow,
|
|
||||||
}
|
|
||||||
)} >
|
|
||||||
<div className={cn("min-h-5 flex items-center text-xs px-1", {
|
|
||||||
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
|
|
||||||
})} title={cell}>
|
|
||||||
{cell}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table >
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
|
export default CSVRawDataPreview
|
||||||
|
|
||||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
|
|
||||||
if (!columnType) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Amount') {
|
|
||||||
return <DollarSignIcon className="w-4 h-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Withdrawal') {
|
|
||||||
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Deposit') {
|
|
||||||
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Balance') {
|
|
||||||
return <BanknoteIcon className="w-4 h-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Date') {
|
|
||||||
return <CalendarIcon className="w-4 h-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Description') {
|
|
||||||
return <FileTextIcon className="w-4 h-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Reference') {
|
|
||||||
return <ReceiptIcon className="w-4 h-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Transaction Type') {
|
|
||||||
return <ListIcon className="w-4 h-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
if (columnType === 'Debit/Credit') {
|
|
||||||
return <ArrowUpDownIcon className="w-4 h-4" />
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CSVRawDataPreview
|
|
||||||
|
|||||||
@@ -142,11 +142,16 @@ const StatementDetails = ({ data }: Props) => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<BankLogo bank={bank} />
|
<BankLogo bank={bank} />
|
||||||
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
|
<span className="text-sm">{bank?.account_name}</span>
|
||||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{_("Account")}</TableHead>
|
||||||
|
<TableCell>
|
||||||
|
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{_("Statement File")}</TableHead>
|
<TableHead>{_("Statement File")}</TableHead>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -158,7 +163,11 @@ const StatementDetails = ({ data }: Props) => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{_("Transaction Dates")}</TableHead>
|
<TableHead>{_("Transaction Dates")}</TableHead>
|
||||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
{data.doc.start_date && data.doc.end_date ? (
|
||||||
|
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||||
|
) : (
|
||||||
|
<TableCell>-</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{_("Number of Transactions")}</TableHead>
|
<TableHead>{_("Number of Transactions")}</TableHead>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { RefObject, useEffect, useRef, useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type Bbox = [number, number, number, number]
|
||||||
|
|
||||||
|
const MIN_SIZE = 8 // PDF points
|
||||||
|
|
||||||
|
// Keep the box valid: normalise flipped edges, enforce a min size, clamp to the page.
|
||||||
|
const clampBbox = (bbox: Bbox, pageWidth: number, pageHeight: number): Bbox => {
|
||||||
|
let [x0, top, x1, bottom] = bbox
|
||||||
|
if (x1 < x0) [x0, x1] = [x1, x0]
|
||||||
|
if (bottom < top) [top, bottom] = [bottom, top]
|
||||||
|
x0 = Math.max(0, Math.min(x0, pageWidth - MIN_SIZE))
|
||||||
|
top = Math.max(0, Math.min(top, pageHeight - MIN_SIZE))
|
||||||
|
x1 = Math.min(pageWidth, Math.max(x1, x0 + MIN_SIZE))
|
||||||
|
bottom = Math.min(pageHeight, Math.max(bottom, top + MIN_SIZE))
|
||||||
|
return [x0, top, x1, bottom]
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDLES = [
|
||||||
|
{ id: 'nw', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
|
||||||
|
{ id: 'ne', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
|
||||||
|
{ id: 'sw', className: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
|
||||||
|
{ id: 'se', className: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bbox: Bbox
|
||||||
|
pageWidth: number
|
||||||
|
pageHeight: number
|
||||||
|
color: { border: string; bg: string; swatch: string }
|
||||||
|
label: string
|
||||||
|
included: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>
|
||||||
|
onCommit: (bbox: Bbox) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A draggable + corner-resizable rectangle over a rendered PDF page. Coordinates are in PDF
|
||||||
|
* points (top-left origin); pixel deltas are converted using the container's rendered size. */
|
||||||
|
const BBoxOverlay = ({ bbox, pageWidth, pageHeight, color, label, included, disabled, containerRef, onCommit }: Props) => {
|
||||||
|
const [draft, setDraft] = useState<Bbox>(bbox)
|
||||||
|
const draftRef = useRef<Bbox>(bbox)
|
||||||
|
const drag = useRef<{ mode: string; startX: number; startY: number; start: Bbox } | null>(null)
|
||||||
|
|
||||||
|
// Reset to the authoritative bbox whenever it changes (e.g. after a server re-extract).
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(bbox)
|
||||||
|
draftRef.current = bbox
|
||||||
|
}, [bbox])
|
||||||
|
|
||||||
|
const apply = (next: Bbox) => {
|
||||||
|
draftRef.current = next
|
||||||
|
setDraft(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const mode = (e.target as HTMLElement).dataset.handle ?? 'move'
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
drag.current = { mode, startX: e.clientX, startY: e.clientY, start: draftRef.current }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent) => {
|
||||||
|
if (!drag.current || !containerRef.current) return
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
const dx = ((e.clientX - drag.current.startX) / rect.width) * pageWidth
|
||||||
|
const dy = ((e.clientY - drag.current.startY) / rect.height) * pageHeight
|
||||||
|
let [x0, top, x1, bottom] = drag.current.start
|
||||||
|
const m = drag.current.mode
|
||||||
|
if (m === 'move') {
|
||||||
|
x0 += dx
|
||||||
|
x1 += dx
|
||||||
|
top += dy
|
||||||
|
bottom += dy
|
||||||
|
} else {
|
||||||
|
if (m.includes('w')) x0 += dx
|
||||||
|
if (m.includes('e')) x1 += dx
|
||||||
|
if (m.includes('n')) top += dy
|
||||||
|
if (m.includes('s')) bottom += dy
|
||||||
|
}
|
||||||
|
apply(clampBbox([x0, top, x1, bottom], pageWidth, pageHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = (e: React.PointerEvent) => {
|
||||||
|
if (!drag.current) return
|
||||||
|
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||||
|
drag.current = null
|
||||||
|
onCommit(draftRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x0, top, x1, bottom] = draft
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute touch-none border-2',
|
||||||
|
color.border,
|
||||||
|
included ? color.bg : 'opacity-40',
|
||||||
|
disabled ? 'pointer-events-none' : 'cursor-move',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${(x0 / pageWidth) * 100}%`,
|
||||||
|
top: `${(top / pageHeight) * 100}%`,
|
||||||
|
width: `${((x1 - x0) / pageWidth) * 100}%`,
|
||||||
|
height: `${((bottom - top) / pageHeight) * 100}%`,
|
||||||
|
}}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
>
|
||||||
|
<span className={cn('pointer-events-none absolute -top-5 left-0 rounded px-1 text-[10px] font-medium text-white', color.swatch)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{!disabled &&
|
||||||
|
HANDLES.map((handle) => (
|
||||||
|
<span
|
||||||
|
key={handle.id}
|
||||||
|
data-handle={handle.id}
|
||||||
|
className={cn('absolute size-2.5 rounded-sm border border-white', color.swatch, handle.className)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BBoxOverlay
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import StatementDetails from '../CSV/StatementDetails'
|
||||||
|
import PDFTableEditor from './PDFTableEditor'
|
||||||
|
import { GetStatementDetailsResponse } from '../import_utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: { message: GetStatementDetailsResponse }
|
||||||
|
mutate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFImport = ({ data, mutate }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex">
|
||||||
|
<div className="w-[45%] p-4 h-[calc(100vh-72px)] overflow-scroll">
|
||||||
|
<StatementDetails data={data.message} />
|
||||||
|
</div>
|
||||||
|
<div className="w-[55%] border-s pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||||
|
<PDFTableEditor data={data.message} mutate={mutate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PDFImport
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, FileTextIcon, Loader2Icon, TableIcon } from 'lucide-react'
|
||||||
|
import _ from '@/lib/translate'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { H3, Paragraph } from '@/components/ui/typography'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import ErrorBanner from '@/components/ui/error-banner'
|
||||||
|
import RawTableGrid from '../RawTableGrid'
|
||||||
|
import BBoxOverlay from './BBoxOverlay'
|
||||||
|
import {
|
||||||
|
applyColumnMappingChange,
|
||||||
|
ColumnMapsTo,
|
||||||
|
GetStatementDetailsResponse,
|
||||||
|
PDFTable,
|
||||||
|
useReextractPDFTable,
|
||||||
|
useSetPDFTableHeader,
|
||||||
|
useUpdatePDFTables,
|
||||||
|
} from '../import_utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: GetStatementDetailsResponse
|
||||||
|
mutate: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct overlay colours per table on a page.
|
||||||
|
const OVERLAY_COLORS = [
|
||||||
|
{ border: 'border-blue-500', bg: 'bg-blue-500/10', swatch: 'bg-blue-500' },
|
||||||
|
{ border: 'border-purple-500', bg: 'bg-purple-500/10', swatch: 'bg-purple-500' },
|
||||||
|
{ border: 'border-amber-500', bg: 'bg-amber-500/10', swatch: 'bg-amber-500' },
|
||||||
|
{ border: 'border-teal-500', bg: 'bg-teal-500/10', swatch: 'bg-teal-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columnMappingRecord = (table: PDFTable): Record<number, ColumnMapsTo> => {
|
||||||
|
const map: Record<number, ColumnMapsTo> = {}
|
||||||
|
table.column_mapping?.forEach((col) => {
|
||||||
|
map[col.index] = col.maps_to
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFTableEditor = ({ data, mutate }: Props) => {
|
||||||
|
const isCompleted = data.doc.status === 'Completed'
|
||||||
|
|
||||||
|
const [tables, setTables] = useState<PDFTable[]>(() => data.pdf_tables ?? [])
|
||||||
|
const [viewMode, setViewMode] = useState<'pdf' | 'table'>('pdf')
|
||||||
|
const [pageIndex, setPageIndex] = useState(0)
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const toggleCollapsed = (tableIndex: number) =>
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(tableIndex)) {
|
||||||
|
next.delete(tableIndex)
|
||||||
|
} else {
|
||||||
|
next.add(tableIndex)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const { call, loading, error } = useUpdatePDFTables()
|
||||||
|
const { call: reextract, loading: reextracting } = useReextractPDFTable()
|
||||||
|
const { call: setHeaderCall, loading: settingHeader } = useSetPDFTableHeader()
|
||||||
|
const busy = loading || reextracting || settingHeader
|
||||||
|
|
||||||
|
// Persist edits automatically (debounced) so the transaction preview updates in realtime.
|
||||||
|
const tablesRef = useRef(tables)
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
const reextractTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
|
const scheduleSave = () => {
|
||||||
|
if (isCompleted) return
|
||||||
|
clearTimeout(saveTimer.current)
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
call({ statement_import_id: data.doc.name, tables: tablesRef.current })
|
||||||
|
.then(() => mutate())
|
||||||
|
.catch(() => toast.error(_('Could not save the table settings.')))
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a bbox change, re-extract that table's rows from the new region (debounced).
|
||||||
|
// The target is read inside the timeout so it always reflects the committed bbox.
|
||||||
|
const scheduleReextract = (tableIndex: number) => {
|
||||||
|
if (isCompleted) return
|
||||||
|
clearTimeout(reextractTimer.current)
|
||||||
|
reextractTimer.current = setTimeout(() => {
|
||||||
|
const target = tablesRef.current[tableIndex]
|
||||||
|
reextract({
|
||||||
|
statement_import_id: data.doc.name,
|
||||||
|
page: target.page,
|
||||||
|
table_index: target.table_index,
|
||||||
|
bbox: target.bbox,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
commitTables(res?.message?.pdf_tables ?? [])
|
||||||
|
mutate()
|
||||||
|
})
|
||||||
|
.catch(() => toast.error(_('Could not re-extract the table.')))
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
clearTimeout(saveTimer.current)
|
||||||
|
clearTimeout(reextractTimer.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pages = useMemo(() => Array.from(new Set(tables.map((t) => t.page))).sort((a, b) => a - b), [tables])
|
||||||
|
const currentPage = pages[pageIndex]
|
||||||
|
// Keep the table's position in the flat array so edits target the right one.
|
||||||
|
const pageTables = useMemo(
|
||||||
|
() => tables.map((table, index) => ({ table, index })).filter((t) => t.table.page === currentPage),
|
||||||
|
[tables, currentPage],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep tablesRef in sync synchronously so the debounced save/re-extract never read stale state.
|
||||||
|
const commitTables = (next: PDFTable[]) => {
|
||||||
|
tablesRef.current = next
|
||||||
|
setTables(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTable = (tableIndex: number, updater: (table: PDFTable) => PDFTable) => {
|
||||||
|
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? updater(t) : t)))
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeMapping = (tableIndex: number, columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||||
|
updateTable(tableIndex, (table) => ({
|
||||||
|
...table,
|
||||||
|
column_mapping: applyColumnMappingChange(table.column_mapping, columnIndex, mapsTo),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleIncluded = (tableIndex: number, included: boolean) =>
|
||||||
|
updateTable(tableIndex, (table) => ({ ...table, included }))
|
||||||
|
|
||||||
|
const onBboxCommit = (tableIndex: number, bbox: [number, number, number, number]) => {
|
||||||
|
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, bbox } : t)))
|
||||||
|
scheduleReextract(tableIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set/clear the header row of a table; the backend re-derives the column mapping.
|
||||||
|
const onSetHeader = (tableIndex: number, headerIndex: number | null) => {
|
||||||
|
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, header_index: headerIndex } : t)))
|
||||||
|
const target = tablesRef.current[tableIndex]
|
||||||
|
setHeaderCall({
|
||||||
|
statement_import_id: data.doc.name,
|
||||||
|
page: target.page,
|
||||||
|
table_index: target.table_index,
|
||||||
|
header_index: headerIndex ?? -1,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
commitTables(res?.message?.pdf_tables ?? [])
|
||||||
|
mutate()
|
||||||
|
})
|
||||||
|
.catch(() => toast.error(_('Could not update the header row.')))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tables.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||||
|
{_('No tables were extracted from this PDF.')}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<H3 className="text-base border-0 p-0">{_('Detected Tables')}</H3>
|
||||||
|
<Paragraph className="text-p-sm">
|
||||||
|
{_('Review each page. In the Table view, map each column, click a row number to set/clear the header row, and exclude anything that is not transactions (ads, summaries).')}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner error={error} />}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'pdf' | 'table')}>
|
||||||
|
<TabsList variant="subtle">
|
||||||
|
<TabsTrigger value="pdf"><FileTextIcon />{_('PDF')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="table"><TableIcon />{_('Table')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{busy && (
|
||||||
|
<span className="flex items-center gap-1 pe-1 text-xs text-ink-gray-5">
|
||||||
|
<Loader2Icon className="size-3 animate-spin" />
|
||||||
|
{reextracting ? _('Re-extracting') : _('Saving')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
isIconButton
|
||||||
|
disabled={pageIndex === 0}
|
||||||
|
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-24 text-center text-sm text-ink-gray-7">
|
||||||
|
{_('Page {0} of {1}', [currentPage.toString(), pages.length.toString()])}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
isIconButton
|
||||||
|
disabled={pageIndex >= pages.length - 1}
|
||||||
|
onClick={() => setPageIndex((i) => Math.min(pages.length - 1, i + 1))}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'pdf' ? (
|
||||||
|
<PageView
|
||||||
|
pageTables={pageTables}
|
||||||
|
disabled={isCompleted}
|
||||||
|
onToggleIncluded={onToggleIncluded}
|
||||||
|
onBboxCommit={onBboxCommit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{pageTables.map(({ table, index }, position) => {
|
||||||
|
const isCollapsed = collapsed.has(index)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn('flex flex-col rounded border border-outline-gray-2', !table.included && 'opacity-60')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-2">
|
||||||
|
<span className="ps-1 text-sm font-medium text-ink-gray-8">
|
||||||
|
{_('Table {0}', [(position + 1).toString()])}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IncludeToggle
|
||||||
|
id={`tbl-${index}`}
|
||||||
|
checked={table.included}
|
||||||
|
disabled={isCompleted}
|
||||||
|
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="sm" isIconButton onClick={() => toggleCollapsed(index)}>
|
||||||
|
<ChevronDownIcon className={cn('transition-transform', isCollapsed && '-rotate-90')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="overflow-auto border-t border-outline-gray-2">
|
||||||
|
<RawTableGrid
|
||||||
|
rows={table.rows}
|
||||||
|
columnMapping={columnMappingRecord(table)}
|
||||||
|
headerIndex={table.header_index}
|
||||||
|
editable
|
||||||
|
disabled={isCompleted}
|
||||||
|
onChangeMapping={(columnIndex, mapsTo) => onChangeMapping(index, columnIndex, mapsTo)}
|
||||||
|
onSetHeader={(rowIndex) => onSetHeader(index, rowIndex)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageViewProps = {
|
||||||
|
pageTables: { table: PDFTable; index: number }[]
|
||||||
|
disabled: boolean
|
||||||
|
onToggleIncluded: (tableIndex: number, included: boolean) => void
|
||||||
|
onBboxCommit: (tableIndex: number, bbox: [number, number, number, number]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageView = ({ pageTables, disabled, onToggleIncluded, onBboxCommit }: PageViewProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const pageImage = pageTables[0]?.table.page_image
|
||||||
|
const pageWidth = pageTables[0]?.table.page_width ?? 1
|
||||||
|
const pageHeight = pageTables[0]?.table.page_height ?? 1
|
||||||
|
|
||||||
|
if (!pageImage) {
|
||||||
|
return (
|
||||||
|
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||||
|
{_('No page image is available for this page.')}
|
||||||
|
</Paragraph>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{!disabled && (
|
||||||
|
<Paragraph className="text-xs text-ink-gray-5">
|
||||||
|
{_('Drag a box to move it, or drag a corner to resize. The table is re-read from the new region automatically.')}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
<div ref={containerRef} className="relative w-full overflow-auto rounded border border-outline-gray-2 bg-surface-gray-1">
|
||||||
|
<img src={pageImage} alt={_('Page preview')} className="w-full" />
|
||||||
|
{pageTables.map(({ table, index }, position) => {
|
||||||
|
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||||
|
return (
|
||||||
|
<BBoxOverlay
|
||||||
|
key={index}
|
||||||
|
bbox={table.bbox}
|
||||||
|
pageWidth={pageWidth}
|
||||||
|
pageHeight={pageHeight}
|
||||||
|
color={color}
|
||||||
|
label={_('Table {0}', [(position + 1).toString()])}
|
||||||
|
included={table.included}
|
||||||
|
disabled={disabled}
|
||||||
|
containerRef={containerRef}
|
||||||
|
onCommit={(bbox) => onBboxCommit(index, bbox)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{pageTables.map(({ table, index }, position) => {
|
||||||
|
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between rounded border border-outline-gray-2 px-2 py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-3 rounded-sm', color.swatch)} />
|
||||||
|
<span className="text-xs">{_('Table {0}', [(position + 1).toString()])}</span>
|
||||||
|
</div>
|
||||||
|
<IncludeToggle
|
||||||
|
id={`pdf-tbl-${index}`}
|
||||||
|
checked={table.included}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IncludeToggle = ({
|
||||||
|
id,
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
onCheckedChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
checked: boolean
|
||||||
|
disabled: boolean
|
||||||
|
onCheckedChange: (checked: boolean) => void
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={id} className="text-xs text-ink-gray-6">{_('Include')}</Label>
|
||||||
|
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default PDFTableEditor
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowDownRightIcon,
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
ArrowUpRightIcon,
|
||||||
|
BanknoteIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
DollarSignIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
ListIcon,
|
||||||
|
ReceiptIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import _ from '@/lib/translate'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/components/ui/table'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { COLUMN_MAPS_TO_OPTIONS, ColumnMapsTo } from './import_utils'
|
||||||
|
|
||||||
|
const AMOUNT_COLUMNS: ColumnMapsTo[] = ['Amount', 'Withdrawal', 'Deposit', 'Balance']
|
||||||
|
const DATE_LIKE = /\d{1,4}[/\-.\s]\d{1,2}[/\-.\s]\d{1,4}|\d{1,2}[\s-][a-z]{3}/i
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
rows: string[][]
|
||||||
|
/** Column index -> mapped field */
|
||||||
|
columnMapping: Record<number, ColumnMapsTo>
|
||||||
|
headerIndex: number | null
|
||||||
|
editable?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onChangeMapping?: (columnIndex: number, mapsTo: ColumnMapsTo) => void
|
||||||
|
/** Set the header row (or null to mark the table as having no header). */
|
||||||
|
onSetHeader?: (rowIndex: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview of extracted rows with CSV-style colour coding: the header row is highlighted,
|
||||||
|
* detected transaction rows are green, and mapped columns are emphasised. When `editable`, a
|
||||||
|
* compact row of column -> field dropdowns sits at the top, and row numbers can be clicked to
|
||||||
|
* set/clear the header row.
|
||||||
|
*/
|
||||||
|
const RawTableGrid = ({ rows, columnMapping, headerIndex, editable, disabled, onChangeMapping, onSetHeader }: Props) => {
|
||||||
|
// Tabular (XLSX) cells can be numbers/dates, not strings - coerce so .trim()/render are safe.
|
||||||
|
const stringRows = useMemo(
|
||||||
|
() => rows.map((row) => row.map((cell) => (cell == null ? '' : String(cell)))),
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
const numColumns = useMemo(() => stringRows.reduce((max, row) => Math.max(max, row.length), 0), [stringRows])
|
||||||
|
|
||||||
|
const validColumns = useMemo(
|
||||||
|
() => Object.entries(columnMapping).filter(([, m]) => m && m !== 'Do not import').map(([i]) => Number(i)),
|
||||||
|
[columnMapping],
|
||||||
|
)
|
||||||
|
const dateColumn = useMemo(() => Object.entries(columnMapping).find(([, m]) => m === 'Date')?.[0], [columnMapping])
|
||||||
|
const amountColumns = useMemo(
|
||||||
|
() => Object.entries(columnMapping).filter(([, m]) => ['Amount', 'Withdrawal', 'Deposit'].includes(m)).map(([i]) => Number(i)),
|
||||||
|
[columnMapping],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approximate the backend's transaction-row detection so the highlighting tracks edits live.
|
||||||
|
const transactionRows = useMemo(() => {
|
||||||
|
const set = new Set<number>()
|
||||||
|
if (dateColumn === undefined) return set
|
||||||
|
const dateIdx = Number(dateColumn)
|
||||||
|
stringRows.forEach((row, index) => {
|
||||||
|
if (index === headerIndex) return
|
||||||
|
const dateCell = (row[dateIdx] ?? '').trim()
|
||||||
|
if (!dateCell || !DATE_LIKE.test(dateCell)) return
|
||||||
|
if (amountColumns.some((c) => (row[c] ?? '').trim() !== '')) set.add(index)
|
||||||
|
})
|
||||||
|
return set
|
||||||
|
}, [stringRows, headerIndex, dateColumn, amountColumns])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table containerClassName="rounded-none">
|
||||||
|
<TableBody>
|
||||||
|
{editable && (
|
||||||
|
<TableRow className="border-b border-outline-gray-2 bg-surface-white hover:bg-surface-white">
|
||||||
|
<TableHead className="w-8 p-1" />
|
||||||
|
{Array.from({ length: numColumns }).map((_unused, columnIndex) => (
|
||||||
|
<TableHead key={columnIndex} className="p-1 align-top">
|
||||||
|
<Select
|
||||||
|
disabled={disabled}
|
||||||
|
value={columnMapping[columnIndex] ?? 'Do not import'}
|
||||||
|
onValueChange={(value) => onChangeMapping?.(columnIndex, value as ColumnMapsTo)}
|
||||||
|
>
|
||||||
|
<SelectTrigger variant="outline" inputSize="sm" className="h-7 w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{COLUMN_MAPS_TO_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<ColumnHeaderIcon columnType={option} />
|
||||||
|
{_(option)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stringRows.map((row, index) => {
|
||||||
|
const isHeaderRow = index === headerIndex
|
||||||
|
const isTransactionRow = transactionRows.has(index)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={index}
|
||||||
|
className={cn({
|
||||||
|
'bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700': isTransactionRow,
|
||||||
|
'bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400': isHeaderRow,
|
||||||
|
'text-ink-gray-5/70': !isTransactionRow && !isHeaderRow,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{editable && onSetHeader ? (
|
||||||
|
<TableCell className="h-px w-8 p-0 text-center">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onSetHeader(isHeaderRow ? null : index)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full items-center justify-center px-1 text-ink-gray-6 hover:bg-surface-gray-3',
|
||||||
|
isHeaderRow && 'font-semibold text-ink-gray-8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{isHeaderRow
|
||||||
|
? _('This is the header row. Click to mark the table as having no header.')
|
||||||
|
: _('Click to set this as the header row.')}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
) : (
|
||||||
|
<TableCell className="w-8 px-1 py-0.5 text-center text-ink-gray-6">{index + 1}</TableCell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.from({ length: numColumns }).map((_unused, cellIndex) => {
|
||||||
|
const columnType = columnMapping[cellIndex]
|
||||||
|
const isValidColumn = validColumns.includes(cellIndex)
|
||||||
|
const isAmountColumn = AMOUNT_COLUMNS.includes(columnType)
|
||||||
|
const cellText = row[cellIndex] ?? ''
|
||||||
|
|
||||||
|
// Read-only header row: icon + label.
|
||||||
|
if (isHeaderRow) {
|
||||||
|
return (
|
||||||
|
<TableCell key={cellIndex} className="max-w-[200px] overflow-hidden text-ellipsis py-1">
|
||||||
|
<div className="flex items-center gap-1 px-1 text-xs font-medium text-ink-gray-8">
|
||||||
|
{columnType && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<ColumnHeaderIcon columnType={columnType} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{_(columnType)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{cellText}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={cellIndex}
|
||||||
|
className={cn('max-w-[200px] overflow-hidden text-ellipsis py-0.5', {
|
||||||
|
'bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400': isValidColumn && isTransactionRow,
|
||||||
|
'text-ink-gray-5': !isValidColumn && isTransactionRow,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('min-h-5 flex items-center px-1 text-xs', {
|
||||||
|
'justify-end': isAmountColumn && isValidColumn && isTransactionRow,
|
||||||
|
})}
|
||||||
|
title={cellText}
|
||||||
|
>
|
||||||
|
{cellText}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ColumnHeaderIcon = ({ columnType }: { columnType?: ColumnMapsTo }) => {
|
||||||
|
switch (columnType) {
|
||||||
|
case 'Amount':
|
||||||
|
return <DollarSignIcon className="size-4" />
|
||||||
|
case 'Withdrawal':
|
||||||
|
return <ArrowUpRightIcon className="size-4 text-ink-red-3" />
|
||||||
|
case 'Deposit':
|
||||||
|
return <ArrowDownRightIcon className="size-4 text-ink-green-3" />
|
||||||
|
case 'Balance':
|
||||||
|
return <BanknoteIcon className="size-4" />
|
||||||
|
case 'Date':
|
||||||
|
return <CalendarIcon className="size-4" />
|
||||||
|
case 'Description':
|
||||||
|
return <FileTextIcon className="size-4" />
|
||||||
|
case 'Reference':
|
||||||
|
return <ReceiptIcon className="size-4" />
|
||||||
|
case 'Transaction Type':
|
||||||
|
return <ListIcon className="size-4" />
|
||||||
|
case 'Debit/Credit':
|
||||||
|
return <ArrowUpDownIcon className="size-4" />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RawTableGrid
|
||||||
@@ -1,6 +1,97 @@
|
|||||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||||
|
|
||||||
|
export type ColumnMapsTo =
|
||||||
|
| "Do not import"
|
||||||
|
| "Date"
|
||||||
|
| "Withdrawal"
|
||||||
|
| "Deposit"
|
||||||
|
| "Amount"
|
||||||
|
| "Description"
|
||||||
|
| "Reference"
|
||||||
|
| "Transaction Type"
|
||||||
|
| "Debit/Credit"
|
||||||
|
| "Balance"
|
||||||
|
| "Included Fee"
|
||||||
|
| "Excluded Fee"
|
||||||
|
| "Party Name/Account Holder"
|
||||||
|
| "Party Account No."
|
||||||
|
| "Party IBAN"
|
||||||
|
|
||||||
|
export type ColumnMappingEntry = {
|
||||||
|
index: number
|
||||||
|
maps_to: ColumnMapsTo | string
|
||||||
|
header_text?: string
|
||||||
|
variable?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a column mapping change, clearing the same mapping from any other column. */
|
||||||
|
export function applyColumnMappingChange<T extends ColumnMappingEntry>(
|
||||||
|
columns: T[],
|
||||||
|
columnIndex: number,
|
||||||
|
mapsTo: ColumnMapsTo,
|
||||||
|
): T[] {
|
||||||
|
const previous = columns.find((c) => c.index === columnIndex)
|
||||||
|
const cleared =
|
||||||
|
mapsTo === "Do not import"
|
||||||
|
? columns
|
||||||
|
: columns.map((c) =>
|
||||||
|
c.index !== columnIndex && c.maps_to === mapsTo
|
||||||
|
? { ...c, maps_to: "Do not import" as ColumnMapsTo }
|
||||||
|
: c,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
...cleared.filter((c) => c.index !== columnIndex),
|
||||||
|
{
|
||||||
|
index: columnIndex,
|
||||||
|
maps_to: mapsTo,
|
||||||
|
header_text: previous?.header_text ?? "",
|
||||||
|
variable: previous?.variable ?? `column_${columnIndex}`,
|
||||||
|
} as T,
|
||||||
|
].sort((a, b) => a.index - b.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLUMN_MAPS_TO_OPTIONS: ColumnMapsTo[] = [
|
||||||
|
"Do not import",
|
||||||
|
"Date",
|
||||||
|
"Description",
|
||||||
|
"Reference",
|
||||||
|
"Withdrawal",
|
||||||
|
"Deposit",
|
||||||
|
"Amount",
|
||||||
|
"Balance",
|
||||||
|
"Debit/Credit",
|
||||||
|
"Transaction Type",
|
||||||
|
"Included Fee",
|
||||||
|
"Excluded Fee",
|
||||||
|
"Party Name/Account Holder",
|
||||||
|
"Party Account No.",
|
||||||
|
"Party IBAN",
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface PDFTableColumn {
|
||||||
|
index: number
|
||||||
|
header_text: string
|
||||||
|
variable?: string
|
||||||
|
maps_to: ColumnMapsTo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PDFTable {
|
||||||
|
page: number
|
||||||
|
table_index: number
|
||||||
|
bbox: [number, number, number, number]
|
||||||
|
page_width: number
|
||||||
|
page_height: number
|
||||||
|
page_image: string | null
|
||||||
|
render_scale: number | null
|
||||||
|
rows: string[][]
|
||||||
|
header_index: number | null
|
||||||
|
column_mapping: PDFTableColumn[]
|
||||||
|
date_format?: string
|
||||||
|
amount_format?: string
|
||||||
|
included: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetStatementDetailsResponse {
|
export interface GetStatementDetailsResponse {
|
||||||
doc: BankStatementImportLog,
|
doc: BankStatementImportLog,
|
||||||
@@ -30,6 +121,7 @@ export interface GetStatementDetailsResponse {
|
|||||||
date_format: string,
|
date_format: string,
|
||||||
raw_data: Array<Array<string>>,
|
raw_data: Array<Array<string>>,
|
||||||
currency: string,
|
currency: string,
|
||||||
|
pdf_tables?: PDFTable[],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGetStatementDetails = (id: string) => {
|
export const useGetStatementDetails = (id: string) => {
|
||||||
@@ -39,4 +131,24 @@ export const useGetStatementDetails = (id: string) => {
|
|||||||
revalidateOnFocus: false
|
revalidateOnFocus: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdatePDFTables = () => {
|
||||||
|
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_pdf_tables")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReextractPDFTable = () => {
|
||||||
|
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.reextract_pdf_table")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSetPDFTableHeader = () => {
|
||||||
|
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_pdf_table_header")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUpdateColumnMapping = () => {
|
||||||
|
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_column_mapping")
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSetHeaderIndex = () => {
|
||||||
|
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_header_index")
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ export const FileTypeIcon = ({
|
|||||||
const getTextColor = () => {
|
const getTextColor = () => {
|
||||||
switch (fileType.toLowerCase()) {
|
switch (fileType.toLowerCase()) {
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
return 'text-red-700'
|
return 'text-ink-red-3'
|
||||||
case 'doc':
|
case 'doc':
|
||||||
case 'docx':
|
case 'docx':
|
||||||
return 'text-[#1A5CBD]'
|
return 'text-[#1A5CBD]'
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ export const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// @ts-expect-error - some errors have _error_message
|
||||||
|
if (error?._error_message) {
|
||||||
|
eMessages.push({
|
||||||
|
// @ts-expect-error - some errors have _error_message
|
||||||
|
message: error?._error_message,
|
||||||
|
title: "Error",
|
||||||
|
indicator: "red"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (eMessages.length === 0) {
|
if (eMessages.length === 0) {
|
||||||
// Get the message from the exception by removing the exc_type
|
// Get the message from the exception by removing the exc_type
|
||||||
const indexOfFirstColon = error?.exception?.indexOf(':')
|
const indexOfFirstColon = error?.exception?.indexOf(':')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, Di
|
|||||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
import ErrorBanner from "@/components/ui/error-banner"
|
import ErrorBanner from "@/components/ui/error-banner"
|
||||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { H3, Paragraph } from "@/components/ui/typography"
|
import { H3, Paragraph } from "@/components/ui/typography"
|
||||||
@@ -16,7 +17,7 @@ import { flt, formatCurrency } from "@/lib/numbers"
|
|||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||||
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList } from "frappe-react-sdk"
|
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList, useFrappeUpdateDoc } from "frappe-react-sdk"
|
||||||
import { useAtom, useAtomValue } from "jotai"
|
import { useAtom, useAtomValue } from "jotai"
|
||||||
import { ListIcon, Loader2Icon } from "lucide-react"
|
import { ListIcon, Loader2Icon } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
@@ -30,11 +31,15 @@ const BankStatementImporter = () => {
|
|||||||
const [selectedBankAccount] = useAtom(selectedBankAccountAtom)
|
const [selectedBankAccount] = useAtom(selectedBankAccountAtom)
|
||||||
|
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
|
||||||
const { upload, error, loading } = useFrappeFileUpload()
|
const { upload, error, loading } = useFrappeFileUpload()
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { createDoc, loading: createLoading, error: createError } = useFrappeCreateDoc<BankStatementImportLog>()
|
const { createDoc, loading: createLoading, error: createError } = useFrappeCreateDoc<BankStatementImportLog>()
|
||||||
|
const { updateDoc, error: updateError } = useFrappeUpdateDoc()
|
||||||
|
|
||||||
|
const isPdf = files[0]?.name?.toLowerCase().endsWith(".pdf") ?? false
|
||||||
|
|
||||||
const onUpload = () => {
|
const onUpload = () => {
|
||||||
|
|
||||||
@@ -44,12 +49,18 @@ const BankStatementImporter = () => {
|
|||||||
|
|
||||||
const id = `new-bank-statement-import-log-${Date.now()}`
|
const id = `new-bank-statement-import-log-${Date.now()}`
|
||||||
|
|
||||||
upload(files[0], {
|
// For protected PDFs, persist the password on the Bank Account so it is reused for
|
||||||
|
// every statement of this account (and is available before the import doc is created).
|
||||||
|
const ensurePassword = isPdf && password
|
||||||
|
? updateDoc("Bank Account", selectedBankAccount.name, { statement_password: password })
|
||||||
|
: Promise.resolve()
|
||||||
|
|
||||||
|
ensurePassword.then(() => upload(files[0], {
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
doctype: "Bank Statement Import Log",
|
doctype: "Bank Statement Import Log",
|
||||||
docname: id,
|
docname: id,
|
||||||
fieldname: 'file'
|
fieldname: 'file'
|
||||||
}).then((file) => {
|
})).then((file) => {
|
||||||
return createDoc("Bank Statement Import Log",
|
return createDoc("Bank Statement Import Log",
|
||||||
// @ts-expect-error - not filling everything else
|
// @ts-expect-error - not filling everything else
|
||||||
{
|
{
|
||||||
@@ -67,6 +78,7 @@ const BankStatementImporter = () => {
|
|||||||
<div className="w-[52%]">
|
<div className="w-[52%]">
|
||||||
{error && <ErrorBanner error={error} />}
|
{error && <ErrorBanner error={error} />}
|
||||||
{createError && <ErrorBanner error={createError} />}
|
{createError && <ErrorBanner error={createError} />}
|
||||||
|
{updateError && <ErrorBanner error={updateError} />}
|
||||||
<div className="py-2 flex flex-col gap-6">
|
<div className="py-2 flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>{_("Company")}<span className="text-ink-red-3">*</span></Label>
|
<Label>{_("Company")}<span className="text-ink-red-3">*</span></Label>
|
||||||
@@ -89,7 +101,7 @@ const BankStatementImporter = () => {
|
|||||||
data-slot="form-description"
|
data-slot="form-description"
|
||||||
className={cn("text-ink-gray-5 text-xs")}
|
className={cn("text-ink-gray-5 text-xs")}
|
||||||
>
|
>
|
||||||
{_("Upload your bank statement file to start the import process. We support CSV, and XLSX files.")}
|
{_("Upload your bank statement file to start the import process. We support CSV, XLSX and PDF files.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -105,10 +117,27 @@ const BankStatementImporter = () => {
|
|||||||
'text/csv': ['.csv'],
|
'text/csv': ['.csv'],
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||||
'application/vnd.ms-excel': ['.xls'],
|
'application/vnd.ms-excel': ['.xls'],
|
||||||
|
'application/pdf': ['.pdf'],
|
||||||
// 'application/xml': ['.xml'],
|
// 'application/xml': ['.xml'],
|
||||||
}}
|
}}
|
||||||
multiple={false}
|
multiple={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isPdf && <div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="pdf-password">{_("PDF Password")}</Label>
|
||||||
|
<Input
|
||||||
|
id="pdf-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={_("Only if the PDF is password protected")}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<p data-slot="form-description" className={cn("text-ink-gray-5 text-p-sm")}>
|
||||||
|
{_("Leave blank to use the password already saved for this bank account (if any). It is stored encrypted and reused for future statements.")}
|
||||||
|
</p>
|
||||||
|
</div>}
|
||||||
</div>}
|
</div>}
|
||||||
<div className="flex justify-end px-4">
|
<div className="flex justify-end px-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -137,9 +166,10 @@ const StatementInstructions = () => {
|
|||||||
<DialogContent className="min-w-7xl">
|
<DialogContent className="min-w-7xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{_("Statement Import Instructions")}</DialogTitle>
|
<DialogTitle>{_("Statement Import Instructions")}</DialogTitle>
|
||||||
<DialogDescription>{_("We support uploading CSV, XLSX and XLS files. Please make sure the file contains the correct columns.")}</DialogDescription>
|
<DialogDescription>{_("We support uploading CSV, XLSX, XLS and PDF files. Please make sure the file contains the correct columns.")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Paragraph className="text-sm">{_("The file should contain the following columns with a distinct header row. You can upload most bank statements as is without changing the columns.")}</Paragraph>
|
<Paragraph className="text-sm">{_("The file should contain the following columns with a distinct header row. You can upload most bank statements as is without changing the columns.")}</Paragraph>
|
||||||
|
<Paragraph className="text-sm text-ink-gray-6">{_("For PDF statements, we auto-detect the tables on each page. You can then confirm each detected table, map its columns, and exclude anything that is not transactions (e.g. ads or summaries). Password-protected PDFs are supported - the password is saved on the bank account and reused.")}</Paragraph>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -231,7 +261,13 @@ const StatementImportLog = () => {
|
|||||||
<TableRow key={item.name} onClick={() => onViewDetails(item.name)} className="cursor-pointer hover:bg-surface-gray-2">
|
<TableRow key={item.name} onClick={() => onViewDetails(item.name)} className="cursor-pointer hover:bg-surface-gray-2">
|
||||||
<TableCell>{formatDate(item.creation, 'Do MMM YYYY')}</TableCell>
|
<TableCell>{formatDate(item.creation, 'Do MMM YYYY')}</TableCell>
|
||||||
<TableCell><Badge theme={item.status === "Completed" ? "green" : "gray"}>{item.status}</Badge></TableCell>
|
<TableCell><Badge theme={item.status === "Completed" ? "green" : "gray"}>{item.status}</Badge></TableCell>
|
||||||
<TableCell>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</TableCell>
|
<TableCell>
|
||||||
|
{item.start_date && item.end_date ? (
|
||||||
|
<span>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</span>
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-end">{item.number_of_transactions}</TableCell>
|
<TableCell className="text-end">{item.number_of_transactions}</TableCell>
|
||||||
<TableCell className="text-end font-numeric">{formatCurrency(flt(item.closing_balance, 2))}</TableCell>
|
<TableCell className="text-end font-numeric">{formatCurrency(flt(item.closing_balance, 2))}</TableCell>
|
||||||
<TableCell><a
|
<TableCell><a
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
|||||||
import { Link, useParams } from 'react-router'
|
import { Link, useParams } from 'react-router'
|
||||||
|
|
||||||
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
||||||
|
const PDFImport = lazy(() => import('@/components/features/BankStatementImporter/PDF/PDFImport'))
|
||||||
|
|
||||||
const ViewBankStatementImportLog = () => {
|
const ViewBankStatementImportLog = () => {
|
||||||
|
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|
||||||
const { data, isLoading, error } = useGetStatementDetails(id ?? "")
|
const { data, isLoading, error, mutate } = useGetStatementDetails(id ?? "")
|
||||||
|
|
||||||
useFrappeDocumentEventListener("Bank Statement Import Log", id ?? "", () => {
|
useFrappeDocumentEventListener("Bank Statement Import Log", id ?? "", () => {
|
||||||
})
|
})
|
||||||
@@ -42,7 +43,13 @@ const ViewBankStatementImportLog = () => {
|
|||||||
<ErrorBanner error={error} />
|
<ErrorBanner error={error} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
return <CSVImport data={data} />
|
const isPdf = data.message.doc.file?.toLowerCase().endsWith('.pdf')
|
||||||
|
|
||||||
|
if (isPdf) {
|
||||||
|
return <PDFImport data={data} mutate={mutate} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CSVImport data={data} mutate={mutate} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ViewBankStatementImportLog
|
export default ViewBankStatementImportLog
|
||||||
@@ -38,6 +38,8 @@ export interface BankAccount{
|
|||||||
branch_code?: string
|
branch_code?: string
|
||||||
/** Bank Account No : Data */
|
/** Bank Account No : Data */
|
||||||
bank_account_no?: string
|
bank_account_no?: string
|
||||||
|
/** Statement PDF Password : Password - Password used to open password-protected PDF statements for this account. Stored encrypted. */
|
||||||
|
statement_password?: string
|
||||||
/** Is Credit Card : Check */
|
/** Is Credit Card : Check */
|
||||||
is_credit_card?: 0 | 1
|
is_credit_card?: 0 | 1
|
||||||
/** Integration ID : Data */
|
/** Integration ID : Data */
|
||||||
|
|||||||
@@ -47,4 +47,6 @@ export interface BankStatementImportLog {
|
|||||||
detected_transaction_ending_index?: number
|
detected_transaction_ending_index?: number
|
||||||
/** Column Mapping : Table - Bank Statement Import Log Column Map */
|
/** Column Mapping : Table - Bank Statement Import Log Column Map */
|
||||||
column_mapping?: BankStatementImportLogColumnMap[]
|
column_mapping?: BankStatementImportLogColumnMap[]
|
||||||
|
/** PDF Tables : JSON - Per-table extraction data for PDF statements */
|
||||||
|
pdf_tables?: string
|
||||||
}
|
}
|
||||||
@@ -358,10 +358,10 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@tybys/wasm-util" "^0.10.1"
|
"@tybys/wasm-util" "^0.10.1"
|
||||||
|
|
||||||
"@oxc-project/types@=0.128.0":
|
"@oxc-project/types@=0.133.0":
|
||||||
version "0.128.0"
|
version "0.133.0"
|
||||||
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.128.0.tgz#efc7524f948ff9e8ab1404ecad1823849c6fe149"
|
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
|
||||||
integrity sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==
|
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
|
||||||
|
|
||||||
"@radix-ui/number@1.1.1":
|
"@radix-ui/number@1.1.1":
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
@@ -1042,95 +1042,95 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
|
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
|
||||||
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
|
integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
|
||||||
|
|
||||||
"@rolldown/binding-android-arm64@1.0.0-rc.18":
|
"@rolldown/binding-android-arm64@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz#3af8b2242086125934a85c1915b76e0a6a2054c1"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz#54ce8f8382213f4a314a0c2f7ba83f81ffeae592"
|
||||||
integrity sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==
|
integrity sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==
|
||||||
|
|
||||||
"@rolldown/binding-darwin-arm64@1.0.0-rc.18":
|
"@rolldown/binding-darwin-arm64@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz#ae0b4467d24ecd6c6589f03d4d4699616ee9649c"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz#388fca1566c14c00c4b446fc3928630e7f0d95fc"
|
||||||
integrity sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==
|
integrity sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==
|
||||||
|
|
||||||
"@rolldown/binding-darwin-x64@1.0.0-rc.18":
|
"@rolldown/binding-darwin-x64@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz#23cf24b0a7b96c8990bbdd8a91e7fd3ba82b00e7"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz#53f57de1f599ecf1db13823cfc88c18fb80954ad"
|
||||||
integrity sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==
|
integrity sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==
|
||||||
|
|
||||||
"@rolldown/binding-freebsd-x64@1.0.0-rc.18":
|
"@rolldown/binding-freebsd-x64@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz#a047a770f94dc451c062b729e5d1cf82e5c6f9c4"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz#6f3fdda1b7aeaac9d268a526804b4fb96e4e35f1"
|
||||||
integrity sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==
|
integrity sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18":
|
"@rolldown/binding-linux-arm-gnueabihf@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz#c0b7f346cbf50301cea669a4632bc63aabe6a72c"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz#d87a454bf585cc9676849377e91d6e375297326f"
|
||||||
integrity sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==
|
integrity sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18":
|
"@rolldown/binding-linux-arm64-gnu@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz#af56373c7996ebe6379207cd699c9f7f705e235d"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz#419fd6bf612cf348f10528cbcd94ebab9607d8d1"
|
||||||
integrity sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==
|
integrity sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==
|
||||||
|
|
||||||
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.18":
|
"@rolldown/binding-linux-arm64-musl@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz#a8f5acd21fcffc8991aa84710e3ae603c4240ea4"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz#fcc6918696bb76844877e1e4930a18fd0d374069"
|
||||||
integrity sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==
|
integrity sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==
|
||||||
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18":
|
"@rolldown/binding-linux-ppc64-gnu@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz#1d4a89e040ff82141fc46e717cfab80b05f7c13f"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz#32aecb7c8dae5d4f2a8cde57a058ec86991542f8"
|
||||||
integrity sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==
|
integrity sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==
|
||||||
|
|
||||||
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18":
|
"@rolldown/binding-linux-s390x-gnu@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz#97c21feeb2ed87d07820f0b2dcc5dd663e7a7f3b"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz#bed9346ea81e6bb8b93cf11f5d88b77db890b763"
|
||||||
integrity sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==
|
integrity sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.18":
|
"@rolldown/binding-linux-x64-gnu@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz#06310d40fe139ccc3c433b361120d337c66ebec2"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz#64c2d26f75dffd9b5a1f97557a00ae77250c8cb7"
|
||||||
integrity sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==
|
integrity sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==
|
||||||
|
|
||||||
"@rolldown/binding-linux-x64-musl@1.0.0-rc.18":
|
"@rolldown/binding-linux-x64-musl@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz#6a711258841f42609b238050cfcd5db13ac136d0"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz#5a45132e8a47659eeaaf3b540c2954a97c860ff3"
|
||||||
integrity sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==
|
integrity sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==
|
||||||
|
|
||||||
"@rolldown/binding-openharmony-arm64@1.0.0-rc.18":
|
"@rolldown/binding-openharmony-arm64@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz#15cb644beeafdbec930d79ed45c2a7c2573eac70"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz#290513068c55e849dc8457a32afee1d7b0acb309"
|
||||||
integrity sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==
|
integrity sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==
|
||||||
|
|
||||||
"@rolldown/binding-wasm32-wasi@1.0.0-rc.18":
|
"@rolldown/binding-wasm32-wasi@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz#ca3a56d11dfd533d743711141b3bb4c1ec10110e"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz#3d9972dbf1a953d3c7afaa4a0f20ef2b2e39f31b"
|
||||||
integrity sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==
|
integrity sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emnapi/core" "1.10.0"
|
"@emnapi/core" "1.10.0"
|
||||||
"@emnapi/runtime" "1.10.0"
|
"@emnapi/runtime" "1.10.0"
|
||||||
"@napi-rs/wasm-runtime" "^1.1.4"
|
"@napi-rs/wasm-runtime" "^1.1.4"
|
||||||
|
|
||||||
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18":
|
"@rolldown/binding-win32-arm64-msvc@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz#8c2117d68331d7de59d24631146d538fc203d27c"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz#a004ab607a16d6f03bcb555728ff888af75773ad"
|
||||||
integrity sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==
|
integrity sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==
|
||||||
|
|
||||||
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.18":
|
"@rolldown/binding-win32-x64-msvc@1.0.3":
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz#bb5c28df3095046778cc1b020ef52fc5ee7b7e70"
|
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz#e2a25b34691a1cc8a1209d7de709063026dd0cdb"
|
||||||
integrity sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==
|
integrity sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==
|
||||||
|
|
||||||
"@rolldown/pluginutils@1.0.0-rc.18":
|
|
||||||
version "1.0.0-rc.18"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz#51cf2589596a179ebe8cbf313f1358c7b51a2fdc"
|
|
||||||
integrity sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==
|
|
||||||
|
|
||||||
"@rolldown/pluginutils@1.0.0-rc.7":
|
"@rolldown/pluginutils@1.0.0-rc.7":
|
||||||
version "1.0.0-rc.7"
|
version "1.0.0-rc.7"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
|
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz#0414869467f0e471a6515d4f506c85fde867e022"
|
||||||
integrity sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==
|
integrity sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==
|
||||||
|
|
||||||
|
"@rolldown/pluginutils@^1.0.0":
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz#e3fcee093fbb5ce765e1ad088ff4de2889f6f9be"
|
||||||
|
integrity sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==
|
||||||
|
|
||||||
"@socket.io/component-emitter@~3.1.0":
|
"@socket.io/component-emitter@~3.1.0":
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
||||||
@@ -3031,10 +3031,10 @@ ms@^2.1.3:
|
|||||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||||
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
|
||||||
|
|
||||||
nanoid@^3.3.11:
|
nanoid@^3.3.12:
|
||||||
version "3.3.11"
|
version "3.3.12"
|
||||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
|
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05"
|
||||||
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
|
integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==
|
||||||
|
|
||||||
natural-compare@^1.4.0:
|
natural-compare@^1.4.0:
|
||||||
version "1.4.0"
|
version "1.4.0"
|
||||||
@@ -3119,22 +3119,17 @@ picocolors@^1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||||
|
|
||||||
picomatch@^4.0.3:
|
|
||||||
version "4.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
|
|
||||||
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
|
|
||||||
|
|
||||||
picomatch@^4.0.4:
|
picomatch@^4.0.4:
|
||||||
version "4.0.4"
|
version "4.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
|
||||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
||||||
|
|
||||||
postcss@^8.5.14:
|
postcss@^8.5.15:
|
||||||
version "8.5.14"
|
version "8.5.15"
|
||||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c"
|
||||||
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
|
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid "^3.3.11"
|
nanoid "^3.3.12"
|
||||||
picocolors "^1.1.1"
|
picocolors "^1.1.1"
|
||||||
source-map-js "^1.2.1"
|
source-map-js "^1.2.1"
|
||||||
|
|
||||||
@@ -3394,29 +3389,29 @@ resolve-from@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||||
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
|
||||||
|
|
||||||
rolldown@1.0.0-rc.18:
|
rolldown@1.0.3:
|
||||||
version "1.0.0-rc.18"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.18.tgz#c597f89a4ce12e6fc918fa91e4f892b340aa92f0"
|
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.3.tgz#db88a3008fb0e28230a00423727ce75ba32121ac"
|
||||||
integrity sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==
|
integrity sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@oxc-project/types" "=0.128.0"
|
"@oxc-project/types" "=0.133.0"
|
||||||
"@rolldown/pluginutils" "1.0.0-rc.18"
|
"@rolldown/pluginutils" "^1.0.0"
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@rolldown/binding-android-arm64" "1.0.0-rc.18"
|
"@rolldown/binding-android-arm64" "1.0.3"
|
||||||
"@rolldown/binding-darwin-arm64" "1.0.0-rc.18"
|
"@rolldown/binding-darwin-arm64" "1.0.3"
|
||||||
"@rolldown/binding-darwin-x64" "1.0.0-rc.18"
|
"@rolldown/binding-darwin-x64" "1.0.3"
|
||||||
"@rolldown/binding-freebsd-x64" "1.0.0-rc.18"
|
"@rolldown/binding-freebsd-x64" "1.0.3"
|
||||||
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.18"
|
"@rolldown/binding-linux-arm-gnueabihf" "1.0.3"
|
||||||
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.18"
|
"@rolldown/binding-linux-arm64-gnu" "1.0.3"
|
||||||
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.18"
|
"@rolldown/binding-linux-arm64-musl" "1.0.3"
|
||||||
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.18"
|
"@rolldown/binding-linux-ppc64-gnu" "1.0.3"
|
||||||
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.18"
|
"@rolldown/binding-linux-s390x-gnu" "1.0.3"
|
||||||
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.18"
|
"@rolldown/binding-linux-x64-gnu" "1.0.3"
|
||||||
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.18"
|
"@rolldown/binding-linux-x64-musl" "1.0.3"
|
||||||
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.18"
|
"@rolldown/binding-openharmony-arm64" "1.0.3"
|
||||||
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.18"
|
"@rolldown/binding-wasm32-wasi" "1.0.3"
|
||||||
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.18"
|
"@rolldown/binding-win32-arm64-msvc" "1.0.3"
|
||||||
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.18"
|
"@rolldown/binding-win32-x64-msvc" "1.0.3"
|
||||||
|
|
||||||
scheduler@^0.27.0:
|
scheduler@^0.27.0:
|
||||||
version "0.27.0"
|
version "0.27.0"
|
||||||
@@ -3540,18 +3535,10 @@ tapable@^2.3.3:
|
|||||||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
|
||||||
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
|
||||||
|
|
||||||
tinyglobby@^0.2.15:
|
tinyglobby@^0.2.15, tinyglobby@^0.2.17:
|
||||||
version "0.2.15"
|
version "0.2.17"
|
||||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
|
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
|
||||||
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
|
integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
|
||||||
dependencies:
|
|
||||||
fdir "^6.5.0"
|
|
||||||
picomatch "^4.0.3"
|
|
||||||
|
|
||||||
tinyglobby@^0.2.16:
|
|
||||||
version "0.2.16"
|
|
||||||
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
|
|
||||||
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
|
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir "^6.5.0"
|
fdir "^6.5.0"
|
||||||
picomatch "^4.0.4"
|
picomatch "^4.0.4"
|
||||||
@@ -3725,16 +3712,16 @@ vfile@^6.0.0:
|
|||||||
"@types/unist" "^3.0.0"
|
"@types/unist" "^3.0.0"
|
||||||
vfile-message "^4.0.0"
|
vfile-message "^4.0.0"
|
||||||
|
|
||||||
vite@^8.0.11:
|
vite@^8.0.16:
|
||||||
version "8.0.11"
|
version "8.0.16"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.11.tgz#d128fe82a0dd24da5127d20560735f1cd7ade0a6"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.16.tgz#ae073866c06563d6634a90169a496e11bd84f1a6"
|
||||||
integrity sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==
|
integrity sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss "^1.32.0"
|
lightningcss "^1.32.0"
|
||||||
picomatch "^4.0.4"
|
picomatch "^4.0.4"
|
||||||
postcss "^8.5.14"
|
postcss "^8.5.15"
|
||||||
rolldown "1.0.0-rc.18"
|
rolldown "1.0.3"
|
||||||
tinyglobby "^0.2.16"
|
tinyglobby "^0.2.17"
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "~2.3.3"
|
fsevents "~2.3.3"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.email import sendmail_to_system_managers
|
from frappe.email import sendmail_to_system_managers
|
||||||
|
from frappe.query_builder.functions import IfNull, Sum
|
||||||
from frappe.utils import (
|
from frappe.utils import (
|
||||||
add_days,
|
add_days,
|
||||||
add_months,
|
add_months,
|
||||||
@@ -53,20 +54,24 @@ def validate_service_stop_date(doc):
|
|||||||
|
|
||||||
|
|
||||||
def build_conditions(process_type, account, company):
|
def build_conditions(process_type, account, company):
|
||||||
conditions = ""
|
if process_type == "Income":
|
||||||
deferred_account = (
|
item = frappe.qb.DocType("Sales Invoice Item")
|
||||||
"item.deferred_revenue_account" if process_type == "Income" else "item.deferred_expense_account"
|
parent = frappe.qb.DocType("Sales Invoice")
|
||||||
)
|
deferred_account = item.deferred_revenue_account
|
||||||
|
else:
|
||||||
|
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||||
|
parent = frappe.qb.DocType("Purchase Invoice")
|
||||||
|
deferred_account = item.deferred_expense_account
|
||||||
|
|
||||||
if account:
|
if account:
|
||||||
conditions += f"AND {deferred_account}={frappe.db.escape(account)}"
|
return deferred_account == account
|
||||||
elif company:
|
elif company:
|
||||||
conditions += f"AND p.company = {frappe.db.escape(company)}"
|
return parent.company == company
|
||||||
|
|
||||||
return conditions
|
return None
|
||||||
|
|
||||||
|
|
||||||
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=""):
|
def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||||
|
|
||||||
if not start_date:
|
if not start_date:
|
||||||
@@ -75,17 +80,25 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
|||||||
end_date = add_days(today(), -1)
|
end_date = add_days(today(), -1)
|
||||||
|
|
||||||
# check for the purchase invoice for which GL entries has to be done
|
# check for the purchase invoice for which GL entries has to be done
|
||||||
invoices = frappe.db.sql_list(
|
item = frappe.qb.DocType("Purchase Invoice Item")
|
||||||
f"""
|
parent = frappe.qb.DocType("Purchase Invoice")
|
||||||
select distinct item.parent
|
query = (
|
||||||
from `tabPurchase Invoice Item` item, `tabPurchase Invoice` p
|
frappe.qb.from_(item)
|
||||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
.inner_join(parent)
|
||||||
and item.enable_deferred_expense = 1 and item.parent=p.name
|
.on(item.parent == parent.name)
|
||||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
.select(item.parent)
|
||||||
{conditions}
|
.distinct()
|
||||||
""",
|
.where(
|
||||||
(end_date, start_date),
|
(item.service_start_date <= end_date)
|
||||||
) # nosec
|
& (item.service_end_date >= start_date)
|
||||||
|
& (item.enable_deferred_expense == 1)
|
||||||
|
& (item.docstatus == 1)
|
||||||
|
& (IfNull(item.amount, 0) > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if conditions is not None:
|
||||||
|
query = query.where(conditions)
|
||||||
|
invoices = query.run(pluck=True)
|
||||||
|
|
||||||
# For each invoice, book deferred expense
|
# For each invoice, book deferred expense
|
||||||
for invoice in invoices:
|
for invoice in invoices:
|
||||||
@@ -96,7 +109,7 @@ def convert_deferred_expense_to_expense(deferred_process, start_date=None, end_d
|
|||||||
send_mail(deferred_process)
|
send_mail(deferred_process)
|
||||||
|
|
||||||
|
|
||||||
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=""):
|
def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_date=None, conditions=None):
|
||||||
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
# book the expense/income on the last day, but it will be trigger on the 1st of month at 12:00 AM
|
||||||
|
|
||||||
if not start_date:
|
if not start_date:
|
||||||
@@ -105,17 +118,25 @@ def convert_deferred_revenue_to_income(deferred_process, start_date=None, end_da
|
|||||||
end_date = add_days(today(), -1)
|
end_date = add_days(today(), -1)
|
||||||
|
|
||||||
# check for the sales invoice for which GL entries has to be done
|
# check for the sales invoice for which GL entries has to be done
|
||||||
invoices = frappe.db.sql_list(
|
item = frappe.qb.DocType("Sales Invoice Item")
|
||||||
f"""
|
parent = frappe.qb.DocType("Sales Invoice")
|
||||||
select distinct item.parent
|
query = (
|
||||||
from `tabSales Invoice Item` item, `tabSales Invoice` p
|
frappe.qb.from_(item)
|
||||||
where item.service_start_date<=%s and item.service_end_date>=%s
|
.inner_join(parent)
|
||||||
and item.enable_deferred_revenue = 1 and item.parent=p.name
|
.on(item.parent == parent.name)
|
||||||
and item.docstatus = 1 and ifnull(item.amount, 0) > 0
|
.select(item.parent)
|
||||||
{conditions}
|
.distinct()
|
||||||
""",
|
.where(
|
||||||
(end_date, start_date),
|
(item.service_start_date <= end_date)
|
||||||
) # nosec
|
& (item.service_end_date >= start_date)
|
||||||
|
& (item.enable_deferred_revenue == 1)
|
||||||
|
& (item.docstatus == 1)
|
||||||
|
& (IfNull(item.amount, 0) > 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if conditions is not None:
|
||||||
|
query = query.where(conditions)
|
||||||
|
invoices = query.run(pluck=True)
|
||||||
|
|
||||||
for invoice in invoices:
|
for invoice in invoices:
|
||||||
doc = frappe.get_doc("Sales Invoice", invoice)
|
doc = frappe.get_doc("Sales Invoice", invoice)
|
||||||
@@ -136,26 +157,39 @@ def get_booking_dates(doc, item, posting_date=None, prev_posting_date=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not prev_posting_date:
|
if not prev_posting_date:
|
||||||
prev_gl_entry = frappe.db.sql(
|
prev_gl_entry = frappe.get_all(
|
||||||
"""
|
"GL Entry",
|
||||||
select name, posting_date from `tabGL Entry` where company=%s and account=%s and
|
filters={
|
||||||
voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
"company": doc.company,
|
||||||
and is_cancelled = 0
|
"account": item.get(deferred_account),
|
||||||
order by posting_date desc limit 1
|
"voucher_type": doc.doctype,
|
||||||
""",
|
"voucher_no": doc.name,
|
||||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
"voucher_detail_no": item.name,
|
||||||
as_dict=True,
|
"is_cancelled": 0,
|
||||||
|
},
|
||||||
|
fields=["name", "posting_date"],
|
||||||
|
order_by="posting_date desc",
|
||||||
|
limit=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
prev_gl_via_je = frappe.db.sql(
|
je = frappe.qb.DocType("Journal Entry")
|
||||||
"""
|
jea = frappe.qb.DocType("Journal Entry Account")
|
||||||
SELECT p.name, p.posting_date FROM `tabJournal Entry` p, `tabJournal Entry Account` c
|
prev_gl_via_je = (
|
||||||
WHERE p.name = c.parent and p.company=%s and c.account=%s
|
frappe.qb.from_(je)
|
||||||
and c.reference_type=%s and c.reference_name=%s
|
.inner_join(jea)
|
||||||
and c.reference_detail_no=%s and c.docstatus < 2 order by posting_date desc limit 1
|
.on(je.name == jea.parent)
|
||||||
""",
|
.select(je.name, je.posting_date)
|
||||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
.where(
|
||||||
as_dict=True,
|
(je.company == doc.company)
|
||||||
|
& (jea.account == item.get(deferred_account))
|
||||||
|
& (jea.reference_type == doc.doctype)
|
||||||
|
& (jea.reference_name == doc.name)
|
||||||
|
& (jea.reference_detail_no == item.name)
|
||||||
|
& (jea.docstatus < 2)
|
||||||
|
)
|
||||||
|
.orderby(je.posting_date, order=frappe.qb.desc)
|
||||||
|
.limit(1)
|
||||||
|
.run(as_dict=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if prev_gl_via_je:
|
if prev_gl_via_je:
|
||||||
@@ -277,26 +311,47 @@ def get_already_booked_amount(doc, item):
|
|||||||
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
|
total_credit_debit, total_credit_debit_currency = "credit", "credit_in_account_currency"
|
||||||
deferred_account = "deferred_expense_account"
|
deferred_account = "deferred_expense_account"
|
||||||
|
|
||||||
gl_entries_details = frappe.db.sql(
|
gle = frappe.qb.DocType("GL Entry")
|
||||||
"""
|
gl_entries_details = (
|
||||||
select sum({}) as total_credit, sum({}) as total_credit_in_account_currency, voucher_detail_no
|
frappe.qb.from_(gle)
|
||||||
from `tabGL Entry` where company=%s and account=%s and voucher_type=%s and voucher_no=%s and voucher_detail_no=%s
|
.select(
|
||||||
and is_cancelled = 0
|
Sum(gle[total_credit_debit]).as_("total_credit"),
|
||||||
group by voucher_detail_no
|
Sum(gle[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||||
""".format(total_credit_debit, total_credit_debit_currency),
|
gle.voucher_detail_no,
|
||||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
)
|
||||||
as_dict=True,
|
.where(
|
||||||
|
(gle.company == doc.company)
|
||||||
|
& (gle.account == item.get(deferred_account))
|
||||||
|
& (gle.voucher_type == doc.doctype)
|
||||||
|
& (gle.voucher_no == doc.name)
|
||||||
|
& (gle.voucher_detail_no == item.name)
|
||||||
|
& (gle.is_cancelled == 0)
|
||||||
|
)
|
||||||
|
.groupby(gle.voucher_detail_no)
|
||||||
|
.run(as_dict=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
journal_entry_details = frappe.db.sql(
|
je = frappe.qb.DocType("Journal Entry")
|
||||||
"""
|
jea = frappe.qb.DocType("Journal Entry Account")
|
||||||
SELECT sum(c.{}) as total_credit, sum(c.{}) as total_credit_in_account_currency, reference_detail_no
|
journal_entry_details = (
|
||||||
FROM `tabJournal Entry` p , `tabJournal Entry Account` c WHERE p.name = c.parent and
|
frappe.qb.from_(je)
|
||||||
p.company = %s and c.account=%s and c.reference_type=%s and c.reference_name=%s and c.reference_detail_no=%s
|
.inner_join(jea)
|
||||||
and p.docstatus < 2 group by reference_detail_no
|
.on(je.name == jea.parent)
|
||||||
""".format(total_credit_debit, total_credit_debit_currency),
|
.select(
|
||||||
(doc.company, item.get(deferred_account), doc.doctype, doc.name, item.name),
|
Sum(jea[total_credit_debit]).as_("total_credit"),
|
||||||
as_dict=True,
|
Sum(jea[total_credit_debit_currency]).as_("total_credit_in_account_currency"),
|
||||||
|
jea.reference_detail_no,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(je.company == doc.company)
|
||||||
|
& (jea.account == item.get(deferred_account))
|
||||||
|
& (jea.reference_type == doc.doctype)
|
||||||
|
& (jea.reference_name == doc.name)
|
||||||
|
& (jea.reference_detail_no == item.name)
|
||||||
|
& (je.docstatus < 2)
|
||||||
|
)
|
||||||
|
.groupby(jea.reference_detail_no)
|
||||||
|
.run(as_dict=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0
|
already_booked_amount = gl_entries_details[0].total_credit if gl_entries_details else 0
|
||||||
|
|||||||
@@ -592,10 +592,12 @@ def update_account_number(
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def merge_account(old: str, new: str):
|
def merge_account(old: str, new: str):
|
||||||
_ensure_idle_system()
|
_ensure_idle_system()
|
||||||
# Validate properties before merging
|
|
||||||
new_account = frappe.get_cached_doc("Account", new)
|
new_account = frappe.get_cached_doc("Account", new)
|
||||||
old_account = frappe.get_cached_doc("Account", old)
|
old_account = frappe.get_cached_doc("Account", old)
|
||||||
|
|
||||||
|
new_account.check_permission("write")
|
||||||
|
old_account.check_permission("write")
|
||||||
|
|
||||||
if not new_account:
|
if not new_account:
|
||||||
throw(_("Account {0} does not exist").format(new))
|
throw(_("Account {0} does not exist").format(new))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
{
|
||||||
|
"country_code": "nz",
|
||||||
|
"name": "New Zealand - Chart of Accounts with Account Numbers",
|
||||||
|
"disabled": "No",
|
||||||
|
"tree": {
|
||||||
|
"Application of Funds (Assets)": {
|
||||||
|
"Current Assets": {
|
||||||
|
"Bank Accounts": {
|
||||||
|
"Business Transaction Account": {
|
||||||
|
"account_number": "11011",
|
||||||
|
"account_type": "Bank"
|
||||||
|
},
|
||||||
|
"Business Savings Account": {
|
||||||
|
"account_number": "11012",
|
||||||
|
"account_type": "Bank"
|
||||||
|
},
|
||||||
|
"account_number": "11010",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Cash on Hand": {
|
||||||
|
"account_number": "11020",
|
||||||
|
"account_type": "Cash"
|
||||||
|
},
|
||||||
|
"Accounts Receivable": {
|
||||||
|
"Debtors": {
|
||||||
|
"account_number": "11210",
|
||||||
|
"account_type": "Receivable"
|
||||||
|
},
|
||||||
|
"Provision for Doubtful Debts": {
|
||||||
|
"account_number": "11220"
|
||||||
|
},
|
||||||
|
"account_number": "11200",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Inventory": {
|
||||||
|
"Stock on Hand": {
|
||||||
|
"account_number": "11311",
|
||||||
|
"account_type": "Stock"
|
||||||
|
},
|
||||||
|
"Work In Progress": {
|
||||||
|
"account_number": "11312",
|
||||||
|
"account_type": "Stock"
|
||||||
|
},
|
||||||
|
"account_number": "11310",
|
||||||
|
"account_type": "Stock",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Prepayments": {
|
||||||
|
"Prepayments": {
|
||||||
|
"account_number": "11411"
|
||||||
|
},
|
||||||
|
"Supplier Advances": {
|
||||||
|
"account_number": "11412"
|
||||||
|
},
|
||||||
|
"Deferred Expense": {
|
||||||
|
"account_number": "11413"
|
||||||
|
},
|
||||||
|
"account_number": "11410",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"GST Receivable": {
|
||||||
|
"account_number": "11510",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"Income Tax Receivable": {
|
||||||
|
"account_number": "11520",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"account_number": "11000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Fixed Assets": {
|
||||||
|
"Plant & Equipment": {
|
||||||
|
"Plant & Equipment": {
|
||||||
|
"account_number": "16011",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Plant & Equipment": {
|
||||||
|
"account_number": "16012",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16010",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Motor Vehicles": {
|
||||||
|
"Motor Vehicles": {
|
||||||
|
"account_number": "16021",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Motor Vehicles": {
|
||||||
|
"account_number": "16022",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16020",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Office Equipment": {
|
||||||
|
"Office Equipment": {
|
||||||
|
"account_number": "16031",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Office Equipment": {
|
||||||
|
"account_number": "16032",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16030",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Buildings": {
|
||||||
|
"Buildings": {
|
||||||
|
"account_number": "16041",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Buildings": {
|
||||||
|
"account_number": "16042",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16040",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Computer Equipment": {
|
||||||
|
"Computer Equipment": {
|
||||||
|
"account_number": "16051",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Computer Equipment": {
|
||||||
|
"account_number": "16052",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16050",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Capital Work in Progress": {
|
||||||
|
"account_number": "16090",
|
||||||
|
"account_type": "Capital Work in Progress"
|
||||||
|
},
|
||||||
|
"account_number": "16000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "10000",
|
||||||
|
"root_type": "Asset"
|
||||||
|
},
|
||||||
|
"Source of Funds (Liabilities)": {
|
||||||
|
"Current Liabilities": {
|
||||||
|
"Accounts Payable": {
|
||||||
|
"Creditors": {
|
||||||
|
"account_number": "21010",
|
||||||
|
"account_type": "Payable"
|
||||||
|
},
|
||||||
|
"account_number": "21000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Goods Received Not Invoiced": {
|
||||||
|
"account_number": "21100",
|
||||||
|
"account_type": "Stock Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Asset Received Not Invoiced": {
|
||||||
|
"account_number": "21110",
|
||||||
|
"account_type": "Asset Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Service Received Not Invoiced": {
|
||||||
|
"account_number": "21120",
|
||||||
|
"account_type": "Service Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Accrued Expenses": {
|
||||||
|
"account_number": "21200"
|
||||||
|
},
|
||||||
|
"Wages Payable": {
|
||||||
|
"account_number": "21300"
|
||||||
|
},
|
||||||
|
"PAYE Payable": {
|
||||||
|
"account_number": "22010"
|
||||||
|
},
|
||||||
|
"KiwiSaver Payable": {
|
||||||
|
"account_number": "22020"
|
||||||
|
},
|
||||||
|
"ACC Payable": {
|
||||||
|
"account_number": "22030"
|
||||||
|
},
|
||||||
|
"Credit Cards": {
|
||||||
|
"Business Credit Card": {
|
||||||
|
"account_number": "22110"
|
||||||
|
},
|
||||||
|
"account_number": "22100",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Customer Advances": {
|
||||||
|
"account_number": "22200"
|
||||||
|
},
|
||||||
|
"Deferred Revenue": {
|
||||||
|
"account_number": "22210"
|
||||||
|
},
|
||||||
|
"Provisional Account": {
|
||||||
|
"account_number": "22220"
|
||||||
|
},
|
||||||
|
"Tax Liabilities": {
|
||||||
|
"GST Payable": {
|
||||||
|
"account_number": "22310",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"GST Suspense": {
|
||||||
|
"account_number": "22320",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"FBT Payable": {
|
||||||
|
"account_number": "22330",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"Income Tax Payable": {
|
||||||
|
"account_number": "22340",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"account_number": "22300",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "21500",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Non-Current Liabilities": {
|
||||||
|
"Bank Loans": {
|
||||||
|
"Bank Loan": {
|
||||||
|
"account_number": "25011"
|
||||||
|
},
|
||||||
|
"account_number": "25010",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Lease Liabilities": {
|
||||||
|
"Lease Liability": {
|
||||||
|
"account_number": "25021"
|
||||||
|
},
|
||||||
|
"account_number": "25020",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Shareholder Loans": {
|
||||||
|
"Shareholder Loan": {
|
||||||
|
"account_number": "25031"
|
||||||
|
},
|
||||||
|
"account_number": "25030",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "25000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "20000",
|
||||||
|
"root_type": "Liability"
|
||||||
|
},
|
||||||
|
"Equity": {
|
||||||
|
"Share Capital": {
|
||||||
|
"account_number": "31010",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"Drawings": {
|
||||||
|
"account_number": "31020",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"Current Year Earnings": {
|
||||||
|
"account_number": "35010",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"Retained Earnings": {
|
||||||
|
"account_number": "35020",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"account_number": "30000",
|
||||||
|
"root_type": "Equity"
|
||||||
|
},
|
||||||
|
"Income": {
|
||||||
|
"Sales": {
|
||||||
|
"account_number": "41010",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"Other Income": {
|
||||||
|
"Interest Income": {
|
||||||
|
"account_number": "47010",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"Rounding Gain/Loss": {
|
||||||
|
"account_number": "47020",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"Foreign Exchange Gain": {
|
||||||
|
"account_number": "47030",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"account_number": "47000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "40000",
|
||||||
|
"root_type": "Income"
|
||||||
|
},
|
||||||
|
"Expenses": {
|
||||||
|
"Cost of Goods Sold": {
|
||||||
|
"Purchases": {
|
||||||
|
"account_number": "51010",
|
||||||
|
"account_type": "Cost of Goods Sold"
|
||||||
|
},
|
||||||
|
"Freight Inwards": {
|
||||||
|
"account_number": "51020",
|
||||||
|
"account_type": "Expenses Included In Valuation"
|
||||||
|
},
|
||||||
|
"Duty and Landing Costs": {
|
||||||
|
"account_number": "51030",
|
||||||
|
"account_type": "Expenses Included In Valuation"
|
||||||
|
},
|
||||||
|
"Stock Adjustment": {
|
||||||
|
"account_number": "51040",
|
||||||
|
"account_type": "Stock Adjustment"
|
||||||
|
},
|
||||||
|
"Stock Write Off": {
|
||||||
|
"account_number": "51050",
|
||||||
|
"account_type": "Stock Adjustment"
|
||||||
|
},
|
||||||
|
"account_number": "51000",
|
||||||
|
"account_type": "Cost of Goods Sold",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Operating Expenses": {
|
||||||
|
"Wages & Salaries": {
|
||||||
|
"account_number": "61010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"KiwiSaver Employer Contribution": {
|
||||||
|
"account_number": "61020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"ACC Levies": {
|
||||||
|
"account_number": "61030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Rent": {
|
||||||
|
"account_number": "65010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Power": {
|
||||||
|
"account_number": "65020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Telephone": {
|
||||||
|
"account_number": "66010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Insurance": {
|
||||||
|
"account_number": "64010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Accounting Fees": {
|
||||||
|
"account_number": "64020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Legal Fees": {
|
||||||
|
"account_number": "64030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Advertising and Marketing": {
|
||||||
|
"account_number": "65030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Repairs and Maintenance": {
|
||||||
|
"account_number": "65040",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Freight and Courier": {
|
||||||
|
"account_number": "65050",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Operating Costs": {
|
||||||
|
"account_number": "65060",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"account_number": "60000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Depreciation and Amortisation": {
|
||||||
|
"Depreciation - Plant & Equipment": {
|
||||||
|
"account_number": "62010",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"Depreciation - Motor Vehicles": {
|
||||||
|
"account_number": "62020",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"Depreciation - Office Equipment": {
|
||||||
|
"account_number": "62030",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"Depreciation - Computer Equipment": {
|
||||||
|
"account_number": "62040",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "62000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Finance Costs": {
|
||||||
|
"Bank Charges": {
|
||||||
|
"account_number": "67010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Interest Expense": {
|
||||||
|
"account_number": "67020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Rounding Off": {
|
||||||
|
"account_number": "67030",
|
||||||
|
"account_type": "Round Off"
|
||||||
|
},
|
||||||
|
"Payment Discounts": {
|
||||||
|
"account_number": "67040",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"account_number": "67000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Income Tax Expense": {
|
||||||
|
"account_number": "81010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Foreign Exchange": {
|
||||||
|
"Exchange Gain/Loss": {
|
||||||
|
"account_number": "82010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Unrealized Exchange Gain/Loss": {
|
||||||
|
"account_number": "82020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"account_number": "82000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Bad Debts": {
|
||||||
|
"account_number": "83010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Write Off": {
|
||||||
|
"account_number": "83020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Gain/Loss on Asset Disposal": {
|
||||||
|
"account_number": "83030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Expenses Included In Asset Valuation": {
|
||||||
|
"account_number": "84010",
|
||||||
|
"account_type": "Expenses Included In Asset Valuation"
|
||||||
|
},
|
||||||
|
"account_number": "50000",
|
||||||
|
"root_type": "Expense"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -570,6 +570,17 @@
|
|||||||
"account_number": "5000",
|
"account_number": "5000",
|
||||||
"is_group": 1,
|
"is_group": 1,
|
||||||
"root_type": "Expense",
|
"root_type": "Expense",
|
||||||
|
"Cost of Goods Sold": {
|
||||||
|
"account_number": "5001",
|
||||||
|
"is_group": 1,
|
||||||
|
"root_type": "Expense",
|
||||||
|
"Cost of Goods Sold": {
|
||||||
|
"account_number": "5010",
|
||||||
|
"is_group": 0,
|
||||||
|
"root_type": "Expense",
|
||||||
|
"account_type": "Cost of Goods Sold"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Operating Expenses": {
|
"Operating Expenses": {
|
||||||
"account_number": "5100",
|
"account_number": "5100",
|
||||||
"is_group": 1,
|
"is_group": 1,
|
||||||
|
|||||||
@@ -198,21 +198,9 @@ def add_dimension_to_budget_doctype(df, doc):
|
|||||||
def delete_accounting_dimension(doc):
|
def delete_accounting_dimension(doc):
|
||||||
doclist = get_doctypes_with_dimensions()
|
doclist = get_doctypes_with_dimensions()
|
||||||
|
|
||||||
frappe.db.sql(
|
frappe.db.delete("Custom Field", filters={"fieldname": doc.fieldname, "dt": ["in", doclist]})
|
||||||
"""
|
|
||||||
DELETE FROM `tabCustom Field`
|
|
||||||
WHERE fieldname = {}
|
|
||||||
AND dt IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
|
|
||||||
tuple([doc.fieldname, *doclist]),
|
|
||||||
)
|
|
||||||
|
|
||||||
frappe.db.sql(
|
frappe.db.delete("Property Setter", filters={"field_name": doc.fieldname, "doc_type": ["in", doclist]})
|
||||||
"""
|
|
||||||
DELETE FROM `tabProperty Setter`
|
|
||||||
WHERE field_name = {}
|
|
||||||
AND doc_type IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
|
|
||||||
tuple([doc.fieldname, *doclist]),
|
|
||||||
)
|
|
||||||
|
|
||||||
budget_against_property = frappe.get_doc("Property Setter", "Budget-budget_against-options")
|
budget_against_property = frappe.get_doc("Property Setter", "Budget-budget_against-options")
|
||||||
value_list = budget_against_property.value.split("\n")[3:]
|
value_list = budget_against_property.value.split("\n")[3:]
|
||||||
@@ -273,13 +261,27 @@ def get_accounting_dimensions(as_list=True):
|
|||||||
|
|
||||||
|
|
||||||
def get_checks_for_pl_and_bs_accounts():
|
def get_checks_for_pl_and_bs_accounts():
|
||||||
return frappe.db.sql(
|
AccountingDimension = frappe.qb.DocType("Accounting Dimension")
|
||||||
"""SELECT p.label, p.disabled, p.fieldname, c.default_dimension, c.company, c.mandatory_for_pl, c.mandatory_for_bs
|
AccountingDimensionDetail = frappe.qb.DocType("Accounting Dimension Detail")
|
||||||
FROM `tabAccounting Dimension`p ,`tabAccounting Dimension Detail` c
|
|
||||||
WHERE p.name = c.parent AND p.disabled = 0""",
|
query = (
|
||||||
as_dict=1,
|
frappe.qb.from_(AccountingDimension)
|
||||||
|
.join(AccountingDimensionDetail)
|
||||||
|
.on(AccountingDimension.name == AccountingDimensionDetail.parent)
|
||||||
|
.select(
|
||||||
|
AccountingDimension.label,
|
||||||
|
AccountingDimension.disabled,
|
||||||
|
AccountingDimension.fieldname,
|
||||||
|
AccountingDimensionDetail.default_dimension,
|
||||||
|
AccountingDimensionDetail.company,
|
||||||
|
AccountingDimensionDetail.mandatory_for_pl,
|
||||||
|
AccountingDimensionDetail.mandatory_for_bs,
|
||||||
|
)
|
||||||
|
.where(AccountingDimension.disabled == 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return query.run(as_dict=1)
|
||||||
|
|
||||||
|
|
||||||
def get_dimension_with_children(doctype, dimensions):
|
def get_dimension_with_children(doctype, dimensions):
|
||||||
if isinstance(dimensions, str):
|
if isinstance(dimensions, str):
|
||||||
|
|||||||
@@ -43,18 +43,19 @@ class AccountingDimensionFilter(Document):
|
|||||||
self.validate_applicable_accounts()
|
self.validate_applicable_accounts()
|
||||||
|
|
||||||
def validate_applicable_accounts(self):
|
def validate_applicable_accounts(self):
|
||||||
accounts = frappe.db.sql(
|
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
|
||||||
"""
|
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
|
||||||
SELECT a.applicable_on_account as account
|
|
||||||
FROM `tabApplicable On Account` a, `tabAccounting Dimension Filter` d
|
query = (
|
||||||
WHERE d.name = a.parent
|
frappe.qb.from_(ApplicableOnAccount)
|
||||||
and d.name != %s
|
.join(AccountingDimensionFilter)
|
||||||
and d.accounting_dimension = %s
|
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
|
||||||
""",
|
.select(ApplicableOnAccount.applicable_on_account.as_("account"))
|
||||||
(self.name, self.accounting_dimension),
|
.where(AccountingDimensionFilter.name != self.name)
|
||||||
as_dict=1,
|
.where(AccountingDimensionFilter.accounting_dimension == self.accounting_dimension)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
accounts = query.run(as_dict=1)
|
||||||
account_list = [d.account for d in accounts]
|
account_list = [d.account for d in accounts]
|
||||||
|
|
||||||
for account in self.get("accounts"):
|
for account in self.get("accounts"):
|
||||||
@@ -69,22 +70,28 @@ class AccountingDimensionFilter(Document):
|
|||||||
|
|
||||||
|
|
||||||
def get_dimension_filter_map():
|
def get_dimension_filter_map():
|
||||||
filters = frappe.db.sql(
|
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
|
||||||
"""
|
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
|
||||||
SELECT
|
AllowedDimension = frappe.qb.DocType("Allowed Dimension")
|
||||||
a.applicable_on_account, d.dimension_value, p.accounting_dimension,
|
|
||||||
p.allow_or_restrict, p.fieldname, a.is_mandatory
|
query = (
|
||||||
FROM
|
frappe.qb.from_(AccountingDimensionFilter)
|
||||||
`tabApplicable On Account` a,
|
.join(ApplicableOnAccount)
|
||||||
`tabAccounting Dimension Filter` p
|
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
|
||||||
LEFT JOIN `tabAllowed Dimension` d ON d.parent = p.name
|
.left_join(AllowedDimension)
|
||||||
WHERE
|
.on(AllowedDimension.parent == AccountingDimensionFilter.name)
|
||||||
p.name = a.parent
|
.select(
|
||||||
AND p.disabled = 0
|
ApplicableOnAccount.applicable_on_account,
|
||||||
""",
|
AllowedDimension.dimension_value,
|
||||||
as_dict=1,
|
AccountingDimensionFilter.accounting_dimension,
|
||||||
|
AccountingDimensionFilter.allow_or_restrict,
|
||||||
|
AccountingDimensionFilter.fieldname,
|
||||||
|
ApplicableOnAccount.is_mandatory,
|
||||||
|
)
|
||||||
|
.where(AccountingDimensionFilter.disabled == 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
filters = query.run(as_dict=1)
|
||||||
dimension_filter_map = {}
|
dimension_filter_map = {}
|
||||||
|
|
||||||
for f in filters:
|
for f in filters:
|
||||||
|
|||||||
@@ -46,23 +46,19 @@ class AccountingPeriod(Document):
|
|||||||
self.name = " - ".join([self.period_name, company_abbr])
|
self.name = " - ".join([self.period_name, company_abbr])
|
||||||
|
|
||||||
def validate_overlap(self):
|
def validate_overlap(self):
|
||||||
existing_accounting_period = frappe.db.sql(
|
AccountingPeriod = frappe.qb.DocType("Accounting Period")
|
||||||
"""select name from `tabAccounting Period`
|
|
||||||
where (
|
query = (
|
||||||
(%(start_date)s between start_date and end_date)
|
frappe.qb.from_(AccountingPeriod)
|
||||||
or (%(end_date)s between start_date and end_date)
|
.select(AccountingPeriod.name)
|
||||||
or (start_date between %(start_date)s and %(end_date)s)
|
.where(AccountingPeriod.start_date <= self.end_date)
|
||||||
or (end_date between %(start_date)s and %(end_date)s)
|
.where(AccountingPeriod.end_date >= self.start_date)
|
||||||
) and name!=%(name)s and company=%(company)s""",
|
.where(AccountingPeriod.name != self.name)
|
||||||
{
|
.where(AccountingPeriod.company == self.company)
|
||||||
"start_date": self.start_date,
|
|
||||||
"end_date": self.end_date,
|
|
||||||
"name": self.name,
|
|
||||||
"company": self.company,
|
|
||||||
},
|
|
||||||
as_dict=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
existing_accounting_period = query.run(as_dict=True)
|
||||||
|
|
||||||
if len(existing_accounting_period) > 0:
|
if len(existing_accounting_period) > 0:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Accounting Period overlaps with {0}").format(existing_accounting_period[0].get("name")),
|
_("Accounting Period overlaps with {0}").format(existing_accounting_period[0].get("name")),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ frappe.ui.form.on("Accounts Settings", {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
|
||||||
|
|
||||||
|
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
|
||||||
},
|
},
|
||||||
enable_immutable_ledger: function (frm) {
|
enable_immutable_ledger: function (frm) {
|
||||||
if (!frm.doc.enable_immutable_ledger) {
|
if (!frm.doc.enable_immutable_ledger) {
|
||||||
@@ -49,3 +52,16 @@ function toggle_tax_settings(frm, field_name) {
|
|||||||
frm.set_value(other_field, 0);
|
frm.set_value(other_field, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function get_transactions(frm) {
|
||||||
|
const transactions = [
|
||||||
|
{ label: __("Journal Entry"), doctype: "Journal Entry" },
|
||||||
|
{ label: __("Payment Entry"), doctype: "Payment Entry" },
|
||||||
|
{ label: __("Purchase Invoice"), doctype: "Purchase Invoice" },
|
||||||
|
{ label: __("Purchase Order"), doctype: "Purchase Order" },
|
||||||
|
{ label: __("Purchase Receipt"), doctype: "Purchase Receipt" },
|
||||||
|
{ label: __("Sales Invoice"), doctype: "Sales Invoice" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,9 +23,9 @@
|
|||||||
"confirm_before_resetting_posting_date",
|
"confirm_before_resetting_posting_date",
|
||||||
"preview_mode",
|
"preview_mode",
|
||||||
"analytics_section",
|
"analytics_section",
|
||||||
|
"enable_discounts_and_margin",
|
||||||
"enable_accounting_dimensions",
|
"enable_accounting_dimensions",
|
||||||
"column_break_vtnr",
|
"column_break_vtnr",
|
||||||
"enable_discounts_and_margin",
|
|
||||||
"journals_section",
|
"journals_section",
|
||||||
"merge_similar_account_heads",
|
"merge_similar_account_heads",
|
||||||
"deferred_accounting_settings_section",
|
"deferred_accounting_settings_section",
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
"print_settings",
|
"print_settings",
|
||||||
"show_inclusive_tax_in_print",
|
"show_inclusive_tax_in_print",
|
||||||
"show_taxes_as_table_in_print",
|
"show_taxes_as_table_in_print",
|
||||||
"column_break_12",
|
|
||||||
"show_payment_schedule_in_print",
|
"show_payment_schedule_in_print",
|
||||||
"item_price_settings_section",
|
"item_price_settings_section",
|
||||||
"maintain_same_internal_transaction_rate",
|
"maintain_same_internal_transaction_rate",
|
||||||
@@ -60,29 +59,30 @@
|
|||||||
"payments_tab",
|
"payments_tab",
|
||||||
"section_break_jpd0",
|
"section_break_jpd0",
|
||||||
"auto_reconcile_payments",
|
"auto_reconcile_payments",
|
||||||
|
"exchange_gain_loss_posting_date",
|
||||||
"auto_reconciliation_job_trigger",
|
"auto_reconciliation_job_trigger",
|
||||||
"reconciliation_queue_size",
|
"reconciliation_queue_size",
|
||||||
"column_break_resa",
|
"column_break_resa",
|
||||||
"exchange_gain_loss_posting_date",
|
|
||||||
"repost_section",
|
"repost_section",
|
||||||
|
"column_break_mfor",
|
||||||
"repost_allowed_types",
|
"repost_allowed_types",
|
||||||
"payment_options_section",
|
"payment_options_section",
|
||||||
|
"fetch_payment_schedule_in_payment_request",
|
||||||
"enable_loyalty_point_program",
|
"enable_loyalty_point_program",
|
||||||
"column_break_ctam",
|
"column_break_ctam",
|
||||||
"fetch_payment_schedule_in_payment_request",
|
|
||||||
"invoicing_settings_tab",
|
"invoicing_settings_tab",
|
||||||
"accounts_transactions_settings_section",
|
"accounts_transactions_settings_section",
|
||||||
"over_billing_allowance",
|
|
||||||
"column_break_11",
|
|
||||||
"role_allowed_to_over_bill",
|
|
||||||
"credit_controller",
|
|
||||||
"make_payment_via_journal_entry",
|
"make_payment_via_journal_entry",
|
||||||
|
"over_billing_allowance",
|
||||||
|
"credit_controller",
|
||||||
|
"role_allowed_to_over_bill",
|
||||||
|
"column_break_11",
|
||||||
"assets_tab",
|
"assets_tab",
|
||||||
"asset_settings_section",
|
"asset_settings_section",
|
||||||
"calculate_depr_using_total_days",
|
|
||||||
"column_break_gjcc",
|
|
||||||
"book_asset_depreciation_entry_automatically",
|
"book_asset_depreciation_entry_automatically",
|
||||||
|
"calculate_depr_using_total_days",
|
||||||
"role_to_notify_on_depreciation_failure",
|
"role_to_notify_on_depreciation_failure",
|
||||||
|
"column_break_gjcc",
|
||||||
"closing_settings_tab",
|
"closing_settings_tab",
|
||||||
"period_closing_settings_section",
|
"period_closing_settings_section",
|
||||||
"ignore_account_closing_balance",
|
"ignore_account_closing_balance",
|
||||||
@@ -91,8 +91,8 @@
|
|||||||
"reports_tab",
|
"reports_tab",
|
||||||
"remarks_section",
|
"remarks_section",
|
||||||
"general_ledger_remarks_length",
|
"general_ledger_remarks_length",
|
||||||
"column_break_lvjk",
|
|
||||||
"receivable_payable_remarks_length",
|
"receivable_payable_remarks_length",
|
||||||
|
"column_break_lvjk",
|
||||||
"accounts_receivable_payable_tuning_section",
|
"accounts_receivable_payable_tuning_section",
|
||||||
"receivable_payable_fetch_method",
|
"receivable_payable_fetch_method",
|
||||||
"default_ageing_range",
|
"default_ageing_range",
|
||||||
@@ -104,13 +104,15 @@
|
|||||||
"show_balance_in_coa",
|
"show_balance_in_coa",
|
||||||
"banking_section",
|
"banking_section",
|
||||||
"enable_party_matching",
|
"enable_party_matching",
|
||||||
|
"automatically_run_rules_on_unreconciled_transactions",
|
||||||
"enable_fuzzy_matching",
|
"enable_fuzzy_matching",
|
||||||
"transfer_match_days",
|
"transfer_match_days",
|
||||||
"automatically_run_rules_on_unreconciled_transactions",
|
|
||||||
"payment_request_section",
|
"payment_request_section",
|
||||||
"create_pr_in_draft_status",
|
"create_pr_in_draft_status",
|
||||||
"budget_section",
|
"budget_section",
|
||||||
"use_legacy_budget_controller"
|
"use_legacy_budget_controller",
|
||||||
|
"document_naming_tab",
|
||||||
|
"transaction_naming_html"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -118,14 +120,14 @@
|
|||||||
"description": "Address used to determine Tax Category in transactions",
|
"description": "Address used to determine Tax Category in transactions",
|
||||||
"fieldname": "determine_address_tax_category_from",
|
"fieldname": "determine_address_tax_category_from",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Determine Address Tax Category From",
|
"label": "Determine Address Tax Category from",
|
||||||
"options": "Billing Address\nShipping Address"
|
"options": "Billing Address\nShipping Address"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "credit_controller",
|
"fieldname": "credit_controller",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Role allowed to bypass Credit Limit",
|
"label": "Role allowed to bypass credit limit",
|
||||||
"options": "Role"
|
"options": "Role"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -133,7 +135,7 @@
|
|||||||
"description": "Enabling this ensures each Purchase Invoice has a unique value in Supplier Invoice No. field within a particular fiscal year",
|
"description": "Enabling this ensures each Purchase Invoice has a unique value in Supplier Invoice No. field within a particular fiscal year",
|
||||||
"fieldname": "check_supplier_invoice_uniqueness",
|
"fieldname": "check_supplier_invoice_uniqueness",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Check Supplier Invoice Number Uniqueness"
|
"label": "Check Supplier invoice number uniqueness"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
@@ -144,27 +146,29 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "1",
|
"default": "1",
|
||||||
|
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#4-unlink-payment-on-cancellation-of-invoice",
|
||||||
"fieldname": "unlink_payment_on_cancellation_of_invoice",
|
"fieldname": "unlink_payment_on_cancellation_of_invoice",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Unlink Payment on Cancellation of Invoice"
|
"label": "Unlink Payment on cancellation of invoice"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "1",
|
"default": "1",
|
||||||
|
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#8-unlink-advance-payment-on-cancellation-of-order",
|
||||||
"fieldname": "unlink_advance_payment_on_cancelation_of_order",
|
"fieldname": "unlink_advance_payment_on_cancelation_of_order",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Unlink Advance Payment on Cancellation of Order"
|
"label": "Unlink Advance Payment on cancellation of order"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "1",
|
"default": "1",
|
||||||
"fieldname": "book_asset_depreciation_entry_automatically",
|
"fieldname": "book_asset_depreciation_entry_automatically",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Book Asset Depreciation Entry Automatically"
|
"label": "Book Asset Depreciation entry automatically"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "1",
|
"default": "1",
|
||||||
"fieldname": "add_taxes_from_item_tax_template",
|
"fieldname": "add_taxes_from_item_tax_template",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Automatically Add Taxes and Charges from Item Tax Template"
|
"label": "Automatically add Taxes and Charges from Item Tax Template"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "print_settings",
|
"fieldname": "print_settings",
|
||||||
@@ -175,17 +179,13 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "show_inclusive_tax_in_print",
|
"fieldname": "show_inclusive_tax_in_print",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Show Inclusive Tax in Print"
|
"label": "Show inclusive tax in print"
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "column_break_12",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "show_payment_schedule_in_print",
|
"fieldname": "show_payment_schedule_in_print",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Show Payment Schedule in Print"
|
"label": "Show Payment Schedule in print"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "currency_exchange_section",
|
"fieldname": "currency_exchange_section",
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
"description": "Payment Terms from orders will be fetched into the invoices as is",
|
"description": "Payment Terms from orders will be fetched into the invoices as is",
|
||||||
"fieldname": "automatically_fetch_payment_terms",
|
"fieldname": "automatically_fetch_payment_terms",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Automatically Fetch Payment Terms from Order/Quotation"
|
"label": "Automatically fetch Payment Terms from Order/Quotation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
"default": "1",
|
"default": "1",
|
||||||
"fieldname": "automatically_process_deferred_accounting_entry",
|
"fieldname": "automatically_process_deferred_accounting_entry",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Automatically Process Deferred Accounting Entry"
|
"label": "Automatically process deferred Accounting entry"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "deferred_accounting_settings_section",
|
"fieldname": "deferred_accounting_settings_section",
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
|
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
|
||||||
"fieldname": "book_deferred_entries_via_journal_entry",
|
"fieldname": "book_deferred_entries_via_journal_entry",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Book Deferred Entries Via Journal Entry"
|
"label": "Book deferred entries via Journal Entry"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
@@ -247,38 +247,37 @@
|
|||||||
"description": "If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually",
|
"description": "If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually",
|
||||||
"fieldname": "submit_journal_entries",
|
"fieldname": "submit_journal_entries",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Submit Journal Entries"
|
"label": "Submit Journal entries"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "Days",
|
"default": "Days",
|
||||||
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
|
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
|
||||||
"fieldname": "book_deferred_entries_based_on",
|
"fieldname": "book_deferred_entries_based_on",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Book Deferred Entries Based On",
|
"label": "Book Deferred entries based on",
|
||||||
"options": "Days\nMonths"
|
"options": "Days\nMonths"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "delete_linked_ledger_entries",
|
"fieldname": "delete_linked_ledger_entries",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction"
|
"label": "Delete Accounting and Stock Ledger entries on deletion of transaction"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"depends_on": "eval: doc.over_billing_allowance > 0",
|
||||||
"description": "Users with this role are allowed to over bill above the allowance percentage",
|
"description": "Users with this role are allowed to over bill above the allowance percentage",
|
||||||
"fieldname": "role_allowed_to_over_bill",
|
"fieldname": "role_allowed_to_over_bill",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Role Allowed to Over Bill ",
|
"label": "Role Allowed to over bill ",
|
||||||
"options": "Role"
|
"options": "Role"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "period_closing_settings_section",
|
"fieldname": "period_closing_settings_section",
|
||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break"
|
||||||
"label": "Period Closing Settings"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "accounts_transactions_settings_section",
|
"fieldname": "accounts_transactions_settings_section",
|
||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break"
|
||||||
"label": "Credit Limit Settings"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_11",
|
"fieldname": "column_break_11",
|
||||||
@@ -363,14 +362,14 @@
|
|||||||
"default": "1",
|
"default": "1",
|
||||||
"fieldname": "show_balance_in_coa",
|
"fieldname": "show_balance_in_coa",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Show Balances in Chart Of Accounts"
|
"label": "Show balances in Chart of Accounts"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"description": "Split Early Payment Discount Loss into Income and Tax Loss",
|
"description": "Split Early Payment Discount Loss into Income and Tax Loss",
|
||||||
"fieldname": "book_tax_discount_loss",
|
"fieldname": "book_tax_discount_loss",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Book Tax Loss on Early Payment Discount"
|
"label": "Book tax loss on early payment discount"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "journals_section",
|
"fieldname": "journals_section",
|
||||||
@@ -382,7 +381,7 @@
|
|||||||
"description": "Rows with Same Account heads will be merged on Ledger",
|
"description": "Rows with Same Account heads will be merged on Ledger",
|
||||||
"fieldname": "merge_similar_account_heads",
|
"fieldname": "merge_similar_account_heads",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Merge Similar Account Heads"
|
"label": "Merge similar Account Heads"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_jpd0",
|
"fieldname": "section_break_jpd0",
|
||||||
@@ -393,13 +392,13 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "auto_reconcile_payments",
|
"fieldname": "auto_reconcile_payments",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Auto Reconcile Payments"
|
"label": "Auto reconcile Payments"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "show_taxes_as_table_in_print",
|
"fieldname": "show_taxes_as_table_in_print",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Show Taxes as Table in Print"
|
"label": "Show taxes as table in print"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
@@ -421,14 +420,14 @@
|
|||||||
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
|
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
|
||||||
"fieldname": "ignore_account_closing_balance",
|
"fieldname": "ignore_account_closing_balance",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Ignore Account Closing Balance"
|
"label": "Ignore Account closing balance"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"description": "Tax Amount will be rounded on a row(items) level",
|
"description": "Tax Amount will be rounded on a row(items) level",
|
||||||
"fieldname": "round_row_wise_tax",
|
"fieldname": "round_row_wise_tax",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Round Tax Amount Row-wise"
|
"label": "Round tax amount row-wise"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "reports_tab",
|
"fieldname": "reports_tab",
|
||||||
@@ -440,14 +439,14 @@
|
|||||||
"description": "Truncates 'Remarks' column to set character length",
|
"description": "Truncates 'Remarks' column to set character length",
|
||||||
"fieldname": "general_ledger_remarks_length",
|
"fieldname": "general_ledger_remarks_length",
|
||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"label": "General Ledger"
|
"label": "General Ledger remarks length"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"description": "Truncates 'Remarks' column to set character length",
|
"description": "Truncates 'Remarks' column to set character length",
|
||||||
"fieldname": "receivable_payable_remarks_length",
|
"fieldname": "receivable_payable_remarks_length",
|
||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"label": "Accounts Receivable/Payable"
|
"label": "Accounts Receivable / Payable remarks length"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_lvjk",
|
"fieldname": "column_break_lvjk",
|
||||||
@@ -481,7 +480,7 @@
|
|||||||
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
|
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
|
||||||
"fieldname": "create_pr_in_draft_status",
|
"fieldname": "create_pr_in_draft_status",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Create in Draft Status"
|
"label": "Create payment requests in Draft status"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_yuug",
|
"fieldname": "column_break_yuug",
|
||||||
@@ -496,14 +495,14 @@
|
|||||||
"description": "Interval should be between 1 to 59 MInutes",
|
"description": "Interval should be between 1 to 59 MInutes",
|
||||||
"fieldname": "auto_reconciliation_job_trigger",
|
"fieldname": "auto_reconciliation_job_trigger",
|
||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"label": "Auto Reconciliation Job Trigger"
|
"label": "Auto Reconciliation job trigger"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "5",
|
"default": "5",
|
||||||
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
|
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
|
||||||
"fieldname": "reconciliation_queue_size",
|
"fieldname": "reconciliation_queue_size",
|
||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"label": "Reconciliation Queue Size"
|
"label": "Reconciliation queue size"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
@@ -517,14 +516,14 @@
|
|||||||
"description": "Only applies for Normal Payments",
|
"description": "Only applies for Normal Payments",
|
||||||
"fieldname": "exchange_gain_loss_posting_date",
|
"fieldname": "exchange_gain_loss_posting_date",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Posting Date Inheritance for Exchange Gain / Loss",
|
"label": "Posting Date inheritance for exchange gain / loss",
|
||||||
"options": "Invoice\nPayment\nReconciliation Date"
|
"options": "Invoice\nPayment\nReconciliation Date"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "Buffered Cursor",
|
"default": "Buffered Cursor",
|
||||||
"fieldname": "receivable_payable_fetch_method",
|
"fieldname": "receivable_payable_fetch_method",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Data Fetch Method",
|
"label": "Data fetch method",
|
||||||
"options": "Buffered Cursor\nUnBuffered Cursor"
|
"options": "Buffered Cursor\nUnBuffered Cursor"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -541,14 +540,14 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "maintain_same_internal_transaction_rate",
|
"fieldname": "maintain_same_internal_transaction_rate",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Maintain Same Rate Throughout Internal Transaction"
|
"label": "Maintain same rate throughout internal Transaction"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "Stop",
|
"default": "Stop",
|
||||||
"depends_on": "maintain_same_internal_transaction_rate",
|
"depends_on": "maintain_same_internal_transaction_rate",
|
||||||
"fieldname": "maintain_same_rate_action",
|
"fieldname": "maintain_same_rate_action",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Action if Same Rate is Not Maintained Throughout Internal Transaction",
|
"label": "Action if same rate is not maintained throughout internal transaction",
|
||||||
"mandatory_depends_on": "maintain_same_internal_transaction_rate",
|
"mandatory_depends_on": "maintain_same_internal_transaction_rate",
|
||||||
"options": "Stop\nWarn"
|
"options": "Stop\nWarn"
|
||||||
},
|
},
|
||||||
@@ -556,7 +555,7 @@
|
|||||||
"depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'",
|
"depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'",
|
||||||
"fieldname": "role_to_override_stop_action",
|
"fieldname": "role_to_override_stop_action",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Role Allowed to Override Stop Action",
|
"label": "Role allowed to override stop action",
|
||||||
"options": "Role"
|
"options": "Role"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -588,7 +587,7 @@
|
|||||||
"description": "If no taxes are set, and Taxes and Charges Template is selected, the system will automatically apply the taxes from the chosen template.",
|
"description": "If no taxes are set, and Taxes and Charges Template is selected, the system will automatically apply the taxes from the chosen template.",
|
||||||
"fieldname": "add_taxes_from_taxes_and_charges_template",
|
"fieldname": "add_taxes_from_taxes_and_charges_template",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Automatically Add Taxes from Taxes and Charges Template"
|
"label": "Automatically add taxes from Taxes and Charges Template"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_ntmi",
|
"fieldname": "column_break_ntmi",
|
||||||
@@ -598,19 +597,20 @@
|
|||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Fetch Valuation Rate for Internal Transaction"
|
"label": "Fetch valuation rate for internal Transaction"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"description": "Enable this if you are experiencing issues with the new budget controller. Uses the older budget validation logic",
|
||||||
"fieldname": "use_legacy_budget_controller",
|
"fieldname": "use_legacy_budget_controller",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Use Legacy Budget Controller"
|
"label": "Use legacy Budget Controller"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "1",
|
"default": "1",
|
||||||
"fieldname": "use_legacy_controller_for_pcv",
|
"fieldname": "use_legacy_controller_for_pcv",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Use Legacy Controller For Period Closing Voucher"
|
"label": "Use legacy controller for Period Closing Voucher"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Users with this role will be notified if the asset depreciation gets failed",
|
"description": "Users with this role will be notified if the asset depreciation gets failed",
|
||||||
@@ -628,7 +628,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "chart_of_accounts_section",
|
"fieldname": "chart_of_accounts_section",
|
||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break",
|
||||||
"label": "Chart Of Accounts"
|
"label": "Chart of Accounts"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "banking_section",
|
"fieldname": "banking_section",
|
||||||
@@ -673,6 +673,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
|
"documentation_url": "https://docs.frappe.io/erpnext/loyalty-program",
|
||||||
"fieldname": "enable_loyalty_point_program",
|
"fieldname": "enable_loyalty_point_program",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Enable Loyalty Point Program"
|
"label": "Enable Loyalty Point Program"
|
||||||
@@ -699,7 +700,7 @@
|
|||||||
"default": "1",
|
"default": "1",
|
||||||
"fieldname": "fetch_payment_schedule_in_payment_request",
|
"fieldname": "fetch_payment_schedule_in_payment_request",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Fetch Payment Schedule In Payment Request"
|
"label": "Fetch Payment Schedule in Payment Request"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "3",
|
"default": "3",
|
||||||
@@ -724,7 +725,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "repost_allowed_types",
|
"fieldname": "repost_allowed_types",
|
||||||
"fieldtype": "Table",
|
"fieldtype": "Table",
|
||||||
"label": "Allowed Doctypes",
|
"label": "Allowed DocTypes",
|
||||||
"options": "Repost Allowed Types"
|
"options": "Repost Allowed Types"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -732,7 +733,21 @@
|
|||||||
"description": "Runs a preview check on save before submission without making any actual changes.",
|
"description": "Runs a preview check on save before submission without making any actual changes.",
|
||||||
"fieldname": "preview_mode",
|
"fieldname": "preview_mode",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Preview Mode"
|
"label": "Preview mode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "document_naming_tab",
|
||||||
|
"fieldtype": "Tab Break",
|
||||||
|
"label": "Document Naming"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "transaction_naming_html",
|
||||||
|
"fieldtype": "HTML"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
|
||||||
|
"fieldname": "column_break_mfor",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
@@ -741,7 +756,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-05-18 12:16:33.679345",
|
"modified": "2026-06-03 13:11:54.721495",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Accounts Settings",
|
"name": "Accounts Settings",
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ class TestAdvancePaymentLedgerEntry(ERPNextTestSuite, AccountsTestMixin):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.create_company()
|
self.company = "_Test Company"
|
||||||
self.create_usd_receivable_account()
|
self.customer = "_Test Customer"
|
||||||
self.create_usd_payable_account()
|
self.supplier = "_Test Supplier"
|
||||||
self.create_item()
|
self.item = "_Test Item"
|
||||||
self.clear_old_entries()
|
self.cash = "Cash - _TC"
|
||||||
|
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||||
|
self.creditors_usd = "_Test Payable USD - _TC"
|
||||||
|
|
||||||
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
|
def create_sales_order(self, qty=1, rate=100, currency="INR", do_not_submit=False):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"column_break_12",
|
"column_break_12",
|
||||||
"branch_code",
|
"branch_code",
|
||||||
"bank_account_no",
|
"bank_account_no",
|
||||||
|
"statement_password",
|
||||||
"address_and_contact",
|
"address_and_contact",
|
||||||
"address_html",
|
"address_html",
|
||||||
"column_break_13",
|
"column_break_13",
|
||||||
@@ -149,6 +150,12 @@
|
|||||||
"label": "Bank Account No",
|
"label": "Bank Account No",
|
||||||
"length": 30
|
"length": 30
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Password used to open password-protected PDF statements for this account. Stored encrypted.",
|
||||||
|
"fieldname": "statement_password",
|
||||||
|
"fieldtype": "Password",
|
||||||
|
"label": "Statement PDF Password"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "address_and_contact",
|
"fieldname": "address_and_contact",
|
||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class BankAccount(Document):
|
|||||||
mask: DF.Data | None
|
mask: DF.Data | None
|
||||||
party: DF.DynamicLink | None
|
party: DF.DynamicLink | None
|
||||||
party_type: DF.Link | None
|
party_type: DF.Link | None
|
||||||
|
statement_password: DF.Password | None
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
def onload(self):
|
def onload(self):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"creation": "2026-04-11 19:48:13.622253",
|
"creation": "2026-04-11 19:48:13.622253",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -7,7 +8,8 @@
|
|||||||
"field_order": [
|
"field_order": [
|
||||||
"bank_account",
|
"bank_account",
|
||||||
"date",
|
"date",
|
||||||
"balance"
|
"balance",
|
||||||
|
"company"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -31,12 +33,20 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Balance",
|
"label": "Balance",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fetch_from": "bank_account.company",
|
||||||
|
"fieldname": "company",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Company",
|
||||||
|
"options": "Company",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-04-11 19:49:45.374695",
|
"modified": "2026-06-16 22:17:48.007982",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Bank Account Balance",
|
"name": "Bank Account Balance",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class BankAccountBalance(Document):
|
|||||||
|
|
||||||
balance: DF.Currency
|
balance: DF.Currency
|
||||||
bank_account: DF.Link
|
bank_account: DF.Link
|
||||||
|
company: DF.Link | None
|
||||||
date: DF.Date
|
date: DF.Date
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from frappe import _, msgprint
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.query_builder import Case
|
from frappe.query_builder import Case
|
||||||
from frappe.query_builder.custom import ConstantColumn
|
from frappe.query_builder.custom import ConstantColumn
|
||||||
from frappe.query_builder.functions import Coalesce, Sum
|
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||||
from frappe.utils import cint, flt, fmt_money, getdate
|
from frappe.utils import cint, flt, fmt_money, getdate
|
||||||
from pypika import Order
|
from pypika import Order
|
||||||
|
|
||||||
@@ -94,6 +94,7 @@ class BankClearance(Document):
|
|||||||
invalid_document = []
|
invalid_document = []
|
||||||
invalid_cheque_date = []
|
invalid_cheque_date = []
|
||||||
entries_to_update = []
|
entries_to_update = []
|
||||||
|
self.check_permission("write")
|
||||||
|
|
||||||
def validate_entry(d):
|
def validate_entry(d):
|
||||||
is_valid = True
|
is_valid = True
|
||||||
@@ -194,14 +195,17 @@ def get_payment_entries_for_bank_clearance(
|
|||||||
.select(
|
.select(
|
||||||
ConstantColumn("Journal Entry").as_("payment_document"),
|
ConstantColumn("Journal Entry").as_("payment_document"),
|
||||||
journal_entry.name.as_("payment_entry"),
|
journal_entry.name.as_("payment_entry"),
|
||||||
journal_entry.cheque_no.as_("cheque_number"),
|
# non-grouped columns are constant per grouped JE name / account (against_account is
|
||||||
journal_entry.cheque_date,
|
# arbitrary per group on MySQL) -> Max() keeps the GROUP BY valid on postgres with the
|
||||||
|
# same value MySQL picked.
|
||||||
|
Max(journal_entry.cheque_no).as_("cheque_number"),
|
||||||
|
Max(journal_entry.cheque_date).as_("cheque_date"),
|
||||||
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
|
Sum(journal_entry_account.debit_in_account_currency).as_("debit"),
|
||||||
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
|
Sum(journal_entry_account.credit_in_account_currency).as_("credit"),
|
||||||
journal_entry.posting_date,
|
Max(journal_entry.posting_date).as_("posting_date"),
|
||||||
journal_entry_account.against_account,
|
Max(journal_entry_account.against_account).as_("against_account"),
|
||||||
journal_entry.clearance_date,
|
Max(journal_entry.clearance_date).as_("clearance_date"),
|
||||||
journal_entry_account.account_currency,
|
Max(journal_entry_account.account_currency).as_("account_currency"),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
(journal_entry_account.account == account)
|
(journal_entry_account.account == account)
|
||||||
@@ -214,12 +218,13 @@ def get_payment_entries_for_bank_clearance(
|
|||||||
|
|
||||||
if not include_reconciled_entries:
|
if not include_reconciled_entries:
|
||||||
journal_entry_query = journal_entry_query.where(
|
journal_entry_query = journal_entry_query.where(
|
||||||
(journal_entry.clearance_date.isnull()) | (journal_entry.clearance_date == "0000-00-00")
|
(journal_entry.clearance_date.isnull())
|
||||||
|
| (journal_entry.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||||
)
|
)
|
||||||
|
|
||||||
journal_entries = (
|
journal_entries = (
|
||||||
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
|
journal_entry_query.groupby(journal_entry_account.account, journal_entry.name)
|
||||||
.orderby(journal_entry.posting_date)
|
.orderby(Max(journal_entry.posting_date))
|
||||||
.orderby(journal_entry.name, order=Order.desc)
|
.orderby(journal_entry.name, order=Order.desc)
|
||||||
).run(as_dict=True)
|
).run(as_dict=True)
|
||||||
|
|
||||||
@@ -289,7 +294,8 @@ def get_payment_entries_for_bank_clearance(
|
|||||||
|
|
||||||
if not include_reconciled_entries:
|
if not include_reconciled_entries:
|
||||||
payment_entry_query = payment_entry_query.where(
|
payment_entry_query = payment_entry_query.where(
|
||||||
(pe.clearance_date.isnull()) | (pe.clearance_date == "0000-00-00")
|
(pe.clearance_date.isnull())
|
||||||
|
| (pe.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||||
)
|
)
|
||||||
|
|
||||||
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
|
payment_entries = (payment_entry_query.orderby(pe.posting_date).orderby(pe.name, order=Order.desc)).run(
|
||||||
@@ -326,7 +332,8 @@ def get_payment_entries_for_bank_clearance(
|
|||||||
|
|
||||||
if not include_reconciled_entries:
|
if not include_reconciled_entries:
|
||||||
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
|
paid_purchase_invoices_query = paid_purchase_invoices_query.where(
|
||||||
(pi.clearance_date.isnull()) | (pi.clearance_date == "0000-00-00")
|
(pi.clearance_date.isnull())
|
||||||
|
| (pi.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||||
)
|
)
|
||||||
|
|
||||||
paid_purchase_invoices = (
|
paid_purchase_invoices = (
|
||||||
@@ -366,7 +373,8 @@ def get_payment_entries_for_bank_clearance(
|
|||||||
|
|
||||||
if not include_reconciled_entries:
|
if not include_reconciled_entries:
|
||||||
pos_sales_invoices_query = pos_sales_invoices_query.where(
|
pos_sales_invoices_query = pos_sales_invoices_query.where(
|
||||||
(si_payment.clearance_date.isnull()) | (si_payment.clearance_date == "0000-00-00")
|
(si_payment.clearance_date.isnull())
|
||||||
|
| (si_payment.clearance_date == ("0000-00-00" if frappe.db.db_type != "postgres" else None))
|
||||||
)
|
)
|
||||||
|
|
||||||
pos_sales_invoices = (
|
pos_sales_invoices = (
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
|||||||
|
|
||||||
frappe.ui.form.on("Bank Guarantee", {
|
frappe.ui.form.on("Bank Guarantee", {
|
||||||
setup: function (frm) {
|
setup: function (frm) {
|
||||||
|
frm.set_query("reference_doctype", function () {
|
||||||
|
return {
|
||||||
|
filters: {
|
||||||
|
name: ["in", ["Sales Order", "Purchase Order"]],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
frm.set_query("bank_account", function () {
|
frm.set_query("bank_account", function () {
|
||||||
return {
|
return {
|
||||||
filters: {
|
filters: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"autoname": "ACC-BG-.YYYY.-.#####",
|
"autoname": "ACC-BG-.YYYY.-.#####",
|
||||||
"creation": "2016-12-17 10:43:35.731631",
|
"creation": "2016-12-17 10:43:35.731631",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -50,8 +51,7 @@
|
|||||||
"fieldname": "reference_doctype",
|
"fieldname": "reference_doctype",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Reference Document Type",
|
"label": "Reference Document Type",
|
||||||
"options": "DocType",
|
"options": "DocType"
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "reference_docname",
|
"fieldname": "reference_docname",
|
||||||
@@ -60,14 +60,14 @@
|
|||||||
"options": "reference_doctype"
|
"options": "reference_doctype"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval: doc.bg_type == \"Receiving\"",
|
"depends_on": "eval: doc.reference_doctype == \"Sales Order\"",
|
||||||
"fieldname": "customer",
|
"fieldname": "customer",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Customer",
|
"label": "Customer",
|
||||||
"options": "Customer"
|
"options": "Customer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval: doc.bg_type == \"Providing\"",
|
"depends_on": "eval: doc.reference_doctype == \"Purchase Order\"",
|
||||||
"fieldname": "supplier",
|
"fieldname": "supplier",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Supplier",
|
"label": "Supplier",
|
||||||
@@ -218,10 +218,11 @@
|
|||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-08-29 11:52:33.550847",
|
"modified": "2026-05-25 18:12:10.768835",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Bank Guarantee",
|
"name": "Bank Guarantee",
|
||||||
|
"naming_rule": "Expression",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import frappe
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.query_builder.custom import ConstantColumn
|
from frappe.query_builder.custom import ConstantColumn
|
||||||
from frappe.query_builder.functions import Sum
|
from frappe.query_builder.functions import Max, Sum
|
||||||
from frappe.utils import cint, create_batch, flt
|
from frappe.utils import cint, create_batch, flt
|
||||||
|
|
||||||
from erpnext import get_default_cost_center
|
from erpnext import get_default_cost_center
|
||||||
@@ -518,6 +518,7 @@ def create_internal_transfer(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||||
|
bank_transaction.check_permission("write")
|
||||||
|
|
||||||
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
|
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
|
||||||
company = frappe.get_cached_value("Account", bank_account, "company")
|
company = frappe.get_cached_value("Account", bank_account, "company")
|
||||||
@@ -778,7 +779,6 @@ def create_bulk_payment_entry_and_reconcile(
|
|||||||
"""
|
"""
|
||||||
Create a payment entry and reconcile it with the bank transaction
|
Create a payment entry and reconcile it with the bank transaction
|
||||||
"""
|
"""
|
||||||
|
|
||||||
output = []
|
output = []
|
||||||
|
|
||||||
for bank_transaction_name in bank_transaction_names:
|
for bank_transaction_name in bank_transaction_names:
|
||||||
@@ -1410,12 +1410,14 @@ def get_je_matching_query(
|
|||||||
Sum(getattr(jea, amount_field)).as_("paid_amount"),
|
Sum(getattr(jea, amount_field)).as_("paid_amount"),
|
||||||
ConstantColumn("Journal Entry").as_("doctype"),
|
ConstantColumn("Journal Entry").as_("doctype"),
|
||||||
je.name,
|
je.name,
|
||||||
je.cheque_no.as_("reference_no"),
|
# non-grouped columns are constant per grouped JE name (party_type/currency come from the
|
||||||
je.cheque_date.as_("reference_date"),
|
# single bank-account line) -> Max() keeps the GROUP BY valid on postgres with the same value
|
||||||
je.pay_to_recd_from.as_("party"),
|
Max(je.cheque_no).as_("reference_no"),
|
||||||
jea.party_type,
|
Max(je.cheque_date).as_("reference_date"),
|
||||||
je.posting_date,
|
Max(je.pay_to_recd_from).as_("party"),
|
||||||
jea.account_currency.as_("currency"),
|
Max(jea.party_type).as_("party_type"),
|
||||||
|
Max(je.posting_date).as_("posting_date"),
|
||||||
|
Max(jea.account_currency).as_("currency"),
|
||||||
)
|
)
|
||||||
.where(je.docstatus == 1)
|
.where(je.docstatus == 1)
|
||||||
.where(je.voucher_type != "Opening Entry")
|
.where(je.voucher_type != "Opening Entry")
|
||||||
@@ -1423,7 +1425,7 @@ def get_je_matching_query(
|
|||||||
.where(jea.account == common_filters.bank_account)
|
.where(jea.account == common_filters.bank_account)
|
||||||
.where(filter_by_date)
|
.where(filter_by_date)
|
||||||
.groupby(je.name)
|
.groupby(je.name)
|
||||||
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
|
.orderby(Max(je.cheque_date) if cint(filter_by_reference_date) else Max(je.posting_date))
|
||||||
)
|
)
|
||||||
|
|
||||||
if frappe.flags.auto_reconcile_vouchers is True:
|
if frappe.flags.auto_reconcile_vouchers is True:
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ from erpnext.tests.utils import ERPNextTestSuite
|
|||||||
|
|
||||||
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.create_company()
|
self.company = "_Test Company"
|
||||||
self.create_customer()
|
self.customer = "_Test Customer"
|
||||||
self.clear_old_entries()
|
self.bank = "HDFC - _TC"
|
||||||
|
self.debit_to = "Debtors - _TC"
|
||||||
bank_dt = qb.DocType("Bank")
|
bank_dt = qb.DocType("Bank")
|
||||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||||
self.create_bank_account()
|
self.create_bank_account()
|
||||||
|
|||||||
@@ -28,7 +28,8 @@
|
|||||||
"detected_transaction_starting_index",
|
"detected_transaction_starting_index",
|
||||||
"detected_transaction_ending_index",
|
"detected_transaction_ending_index",
|
||||||
"section_break_yulq",
|
"section_break_yulq",
|
||||||
"column_mapping"
|
"column_mapping",
|
||||||
|
"pdf_tables"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -128,6 +129,13 @@
|
|||||||
"label": "Column Mapping",
|
"label": "Column Mapping",
|
||||||
"options": "Bank Statement Import Log Column Map"
|
"options": "Bank Statement Import Log Column Map"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Per-table extraction data for PDF statements (rows, bbox, page image, column mapping). Edited via the banking app.",
|
||||||
|
"fieldname": "pdf_tables",
|
||||||
|
"fieldtype": "JSON",
|
||||||
|
"label": "PDF Tables",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default": "Not Started",
|
"default": "Not Started",
|
||||||
"fieldname": "status",
|
"fieldname": "status",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,18 @@ from frappe.utils import getdate
|
|||||||
|
|
||||||
from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log import (
|
from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log import (
|
||||||
BankStatementImportLog,
|
BankStatementImportLog,
|
||||||
|
build_table_transactions,
|
||||||
|
detect_column_mapping,
|
||||||
|
detect_header_row,
|
||||||
|
extract_pdf_tables,
|
||||||
get_float_amount,
|
get_float_amount,
|
||||||
|
get_statement_details,
|
||||||
|
guess_column_mapping_by_content,
|
||||||
|
reextract_pdf_table,
|
||||||
|
set_header_index,
|
||||||
|
set_pdf_table_header,
|
||||||
|
update_column_mapping,
|
||||||
|
update_pdf_tables,
|
||||||
)
|
)
|
||||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
@@ -15,9 +26,9 @@ from erpnext.tests.utils import ERPNextTestSuite
|
|||||||
|
|
||||||
class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.create_company()
|
self.company = "_Test Company"
|
||||||
self.create_customer()
|
self.customer = "_Test Customer"
|
||||||
self.clear_old_entries()
|
self.bank = "HDFC - _TC"
|
||||||
bank_dt = qb.DocType("Bank")
|
bank_dt = qb.DocType("Bank")
|
||||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||||
self.create_bank_account()
|
self.create_bank_account()
|
||||||
@@ -113,6 +124,346 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
|
|||||||
self.assertIsNone(get_float_amount("ABCD"))
|
self.assertIsNone(get_float_amount("ABCD"))
|
||||||
self.assertIsNone(get_float_amount("****"))
|
self.assertIsNone(get_float_amount("****"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# PDF statement import
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_pdf(html: str) -> bytes:
|
||||||
|
import pdfkit
|
||||||
|
|
||||||
|
return pdfkit.from_string(html, False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encrypt(pdf_bytes: bytes, password: str) -> bytes:
|
||||||
|
import io
|
||||||
|
|
||||||
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
|
reader = PdfReader(io.BytesIO(pdf_bytes))
|
||||||
|
writer = PdfWriter()
|
||||||
|
for page in reader.pages:
|
||||||
|
writer.add_page(page)
|
||||||
|
writer.encrypt(password)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
writer.write(buffer)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auto_map(table: dict) -> dict:
|
||||||
|
"""Mimic prepare_pdf_tables' best-effort mapping for a single extracted table."""
|
||||||
|
header_index, score = detect_header_row(table["rows"])
|
||||||
|
if score >= 2:
|
||||||
|
table["header_index"] = header_index
|
||||||
|
table["column_mapping"] = detect_column_mapping(table["rows"][header_index])
|
||||||
|
else:
|
||||||
|
table["header_index"] = None
|
||||||
|
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
|
||||||
|
table["included"] = True
|
||||||
|
return table
|
||||||
|
|
||||||
|
def test_pdf_multi_page_kept_separate_and_unioned(self):
|
||||||
|
"""Tables on separate pages must NOT be merged; transactions are the union."""
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||||
|
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
|
||||||
|
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
|
||||||
|
<div style="page-break-before: always"></div>
|
||||||
|
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||||
|
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
tables = extract_pdf_tables(self._make_pdf(html))
|
||||||
|
|
||||||
|
# Two separate tables, one per page
|
||||||
|
self.assertEqual(len(tables), 2)
|
||||||
|
self.assertEqual(sorted(t["page"] for t in tables), [1, 2])
|
||||||
|
for table in tables:
|
||||||
|
self.assertIn("bbox", table)
|
||||||
|
self.assertEqual(len(table["bbox"]), 4)
|
||||||
|
|
||||||
|
union = []
|
||||||
|
for table in tables:
|
||||||
|
final, _df, _af = build_table_transactions(self._auto_map(table))
|
||||||
|
union.extend(final)
|
||||||
|
|
||||||
|
self.assertEqual(len(union), 3)
|
||||||
|
self.assertEqual(sorted(t["date"] for t in union), ["2024-04-01", "2024-04-03", "2024-04-05"])
|
||||||
|
|
||||||
|
def test_pdf_junk_table_excluded(self):
|
||||||
|
"""A non-transactions table (ad/summary) should yield zero transactions."""
|
||||||
|
ad_table = self._auto_map({"rows": [["Open a new account!", "Call 1800-XYZ"]]})
|
||||||
|
final, _df, _af = build_table_transactions(ad_table)
|
||||||
|
self.assertEqual(final, [])
|
||||||
|
|
||||||
|
def test_headerless_content_mapping(self):
|
||||||
|
"""Without a header row, columns are guessed from their contents."""
|
||||||
|
rows = [
|
||||||
|
["01/04/2024", "UPI PAYMENT", "500.00"],
|
||||||
|
["03/04/2024", "SALARY CREDIT", "20000.00"],
|
||||||
|
]
|
||||||
|
mapping = {
|
||||||
|
c["maps_to"]: c["index"]
|
||||||
|
for c in guess_column_mapping_by_content(rows)
|
||||||
|
if c["maps_to"] != "Do not import"
|
||||||
|
}
|
||||||
|
self.assertEqual(mapping.get("Date"), 0)
|
||||||
|
self.assertEqual(mapping.get("Description"), 1)
|
||||||
|
self.assertEqual(mapping.get("Amount"), 2)
|
||||||
|
|
||||||
|
def test_pdf_password_protected(self):
|
||||||
|
"""Encrypted PDFs error without a password and succeed with the right one."""
|
||||||
|
html = """
|
||||||
|
<html><body><table border="1">
|
||||||
|
<tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||||
|
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr></table></body></html>
|
||||||
|
"""
|
||||||
|
encrypted = self._encrypt(self._make_pdf(html), "secret123")
|
||||||
|
|
||||||
|
# No / wrong password -> recognizable error
|
||||||
|
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted)
|
||||||
|
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted, "wrong")
|
||||||
|
|
||||||
|
# Correct password -> extracts
|
||||||
|
tables = extract_pdf_tables(encrypted, "secret123")
|
||||||
|
self.assertTrue(tables)
|
||||||
|
|
||||||
|
def test_pdf_no_tables_detected(self):
|
||||||
|
"""A PDF with no detectable tables raises a clear error (e.g. scanned PDFs)."""
|
||||||
|
html = "<html><body><p>Just some prose with no tabular data at all.</p></body></html>"
|
||||||
|
self.assertRaises(frappe.ValidationError, extract_pdf_tables, self._make_pdf(html))
|
||||||
|
|
||||||
|
def _create_pdf_import_log(self, html: str) -> BankStatementImportLog:
|
||||||
|
pdf_bytes = self._make_pdf(html)
|
||||||
|
file_doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "File",
|
||||||
|
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.pdf",
|
||||||
|
"is_private": 1,
|
||||||
|
"content": pdf_bytes,
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Bank Statement Import Log",
|
||||||
|
"name": f"test-pdf-{frappe.generate_hash(length=8)}",
|
||||||
|
"bank_account": self.bank_account,
|
||||||
|
"file": file_doc.file_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return doc.insert()
|
||||||
|
|
||||||
|
def test_pdf_full_lifecycle(self):
|
||||||
|
"""End-to-end doc lifecycle: insert -> rasterize -> preview -> edit -> import."""
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||||
|
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
|
||||||
|
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
|
||||||
|
<div style="page-break-before: always"></div>
|
||||||
|
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
|
||||||
|
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
doc = self._create_pdf_import_log(html)
|
||||||
|
|
||||||
|
# before_insert populated the per-table JSON, page images and the union summary
|
||||||
|
tables = doc.get_pdf_tables()
|
||||||
|
self.assertEqual(len(tables), 2)
|
||||||
|
for table in tables:
|
||||||
|
self.assertTrue(table.get("page_image"))
|
||||||
|
self.assertIn("bbox", table)
|
||||||
|
# Page-image File must be attached to the final docname, not the client's temp id
|
||||||
|
attached_to = frappe.db.get_value("File", {"file_url": table["page_image"]}, "attached_to_name")
|
||||||
|
self.assertEqual(attached_to, doc.name)
|
||||||
|
self.assertEqual(doc.number_of_transactions, 3)
|
||||||
|
self.assertEqual(doc.total_debit_transactions, 2)
|
||||||
|
self.assertEqual(doc.total_credit_transactions, 1)
|
||||||
|
|
||||||
|
# get_statement_details returns the union and the per-table data for the editor
|
||||||
|
details = get_statement_details(doc.name)
|
||||||
|
self.assertEqual(len(details["final_transactions"]), 3)
|
||||||
|
self.assertEqual(details["raw_data"], [])
|
||||||
|
self.assertEqual(len(details["pdf_tables"]), 2)
|
||||||
|
|
||||||
|
# Excluding the second table (page 2) drops its single transaction
|
||||||
|
tables[1]["included"] = False
|
||||||
|
update_pdf_tables(doc.name, tables)
|
||||||
|
doc.reload()
|
||||||
|
self.assertEqual(doc.number_of_transactions, 2)
|
||||||
|
|
||||||
|
# Re-include and import; transactions are created for the union
|
||||||
|
tables[1]["included"] = True
|
||||||
|
update_pdf_tables(doc.name, tables)
|
||||||
|
doc.reload()
|
||||||
|
doc.insert_transactions()
|
||||||
|
doc.reload()
|
||||||
|
self.assertEqual(doc.status, "Completed")
|
||||||
|
|
||||||
|
created = frappe.get_all(
|
||||||
|
"Bank Transaction", filters={"bank_account": self.bank_account, "docstatus": 1}
|
||||||
|
)
|
||||||
|
self.assertEqual(len(created), 3)
|
||||||
|
|
||||||
|
def test_pdf_reextract_table_from_bbox(self):
|
||||||
|
"""Re-extracting a table from an adjusted bbox updates its rows and stores the bbox."""
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||||
|
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
|
||||||
|
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
doc = self._create_pdf_import_log(html)
|
||||||
|
table = doc.get_pdf_tables()[0]
|
||||||
|
bbox = table["bbox"]
|
||||||
|
|
||||||
|
details = reextract_pdf_table(doc.name, table["page"], table["table_index"], bbox)
|
||||||
|
updated = details["pdf_tables"][0]
|
||||||
|
|
||||||
|
# Same region -> same rows; bbox is persisted
|
||||||
|
self.assertTrue(updated["rows"])
|
||||||
|
self.assertEqual(updated["bbox"], [round(float(v), 2) for v in bbox])
|
||||||
|
self.assertEqual(updated["rows"], table["rows"])
|
||||||
|
|
||||||
|
def test_pdf_reextract_changed_bbox_updates_rows_and_transactions(self):
|
||||||
|
"""Shrinking a table's bbox must drop rows and update the transaction count end-to-end."""
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||||
|
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
|
||||||
|
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr>
|
||||||
|
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td></tr>
|
||||||
|
<tr><td>07/04/2024</td><td>INTEREST</td><td>12.50</td></tr></table>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
doc = self._create_pdf_import_log(html)
|
||||||
|
original = doc.get_pdf_tables()[0]
|
||||||
|
original_rows = len(original["rows"])
|
||||||
|
original_txns = doc.number_of_transactions
|
||||||
|
|
||||||
|
# Shrink the box to roughly the top half (simulating a user drag).
|
||||||
|
x0, top, x1, bottom = original["bbox"]
|
||||||
|
shrunk = [x0, top, x1, top + (bottom - top) * 0.5]
|
||||||
|
|
||||||
|
details = reextract_pdf_table(doc.name, original["page"], original["table_index"], shrunk)
|
||||||
|
updated = details["pdf_tables"][0]
|
||||||
|
doc.reload()
|
||||||
|
|
||||||
|
self.assertLess(len(updated["rows"]), original_rows)
|
||||||
|
self.assertLess(doc.number_of_transactions, original_txns)
|
||||||
|
self.assertEqual(len(details["final_transactions"]), doc.number_of_transactions)
|
||||||
|
|
||||||
|
def test_pdf_set_table_header(self):
|
||||||
|
"""User can clear a table's header (no header row) or set a specific header row."""
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
|
||||||
|
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
|
||||||
|
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
doc = self._create_pdf_import_log(html)
|
||||||
|
table = doc.get_pdf_tables()[0]
|
||||||
|
self.assertEqual(table["header_index"], 0)
|
||||||
|
original = {
|
||||||
|
c["maps_to"]: c["index"] for c in table["column_mapping"] if c["maps_to"] != "Do not import"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear the header (-1): header is removed but the mapping is preserved (not re-guessed).
|
||||||
|
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], -1)
|
||||||
|
updated = details["pdf_tables"][0]
|
||||||
|
self.assertIsNone(updated["header_index"])
|
||||||
|
preserved = {
|
||||||
|
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
|
||||||
|
}
|
||||||
|
self.assertEqual(preserved, original)
|
||||||
|
|
||||||
|
# Set row 0 back as the header: it resolves meaningfully, so mapping is re-derived.
|
||||||
|
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], 0)
|
||||||
|
updated = details["pdf_tables"][0]
|
||||||
|
self.assertEqual(updated["header_index"], 0)
|
||||||
|
mapped = {
|
||||||
|
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
|
||||||
|
}
|
||||||
|
self.assertEqual(mapped.get("Date"), 0)
|
||||||
|
self.assertEqual(mapped.get("Description"), 1)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# CSV/XLSX column mapping + header overrides
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _create_csv_import_log(self, csv_text: str) -> BankStatementImportLog:
|
||||||
|
file_doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "File",
|
||||||
|
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.csv",
|
||||||
|
"is_private": 1,
|
||||||
|
"content": csv_text,
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Bank Statement Import Log",
|
||||||
|
"bank_account": self.bank_account,
|
||||||
|
"file": file_doc.file_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return doc.insert()
|
||||||
|
|
||||||
|
def test_csv_update_column_mapping(self):
|
||||||
|
"""Overriding the column mapping recomputes the transaction count."""
|
||||||
|
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
|
||||||
|
doc = self._create_csv_import_log(csv_text)
|
||||||
|
self.assertEqual(doc.number_of_transactions, 2)
|
||||||
|
|
||||||
|
# Drop the amount column -> no amount -> no transactions detected.
|
||||||
|
mapping = [
|
||||||
|
{"index": c.index, "maps_to": "Do not import" if c.maps_to == "Amount" else c.maps_to}
|
||||||
|
for c in doc.column_mapping
|
||||||
|
]
|
||||||
|
details = update_column_mapping(doc.name, mapping)
|
||||||
|
doc.reload()
|
||||||
|
self.assertEqual(doc.number_of_transactions, 0)
|
||||||
|
self.assertEqual(len(details["final_transactions"]), 0)
|
||||||
|
|
||||||
|
def test_csv_set_header_index_preserves_mapping(self):
|
||||||
|
"""Clearing the header keeps the user's mapping; it is not re-guessed."""
|
||||||
|
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
|
||||||
|
doc = self._create_csv_import_log(csv_text)
|
||||||
|
self.assertEqual(doc.detected_header_index, 0)
|
||||||
|
|
||||||
|
# Manually map the Narration column (1) as Reference.
|
||||||
|
mapping = [
|
||||||
|
{
|
||||||
|
"index": c.index,
|
||||||
|
"maps_to": "Reference" if c.index == 1 else c.maps_to,
|
||||||
|
"header_text": c.header_text,
|
||||||
|
}
|
||||||
|
for c in doc.column_mapping
|
||||||
|
]
|
||||||
|
update_column_mapping(doc.name, mapping)
|
||||||
|
doc.reload()
|
||||||
|
|
||||||
|
# Clear the header row: the manual mapping must be preserved (column 1 stays Reference,
|
||||||
|
# not re-guessed to Description). The label row fails date parsing, so 2 transactions remain.
|
||||||
|
set_header_index(doc.name, -1)
|
||||||
|
doc.reload()
|
||||||
|
self.assertEqual(doc.detected_header_index, -1)
|
||||||
|
self.assertEqual(doc.number_of_transactions, 2)
|
||||||
|
current = {c.index: c.maps_to for c in doc.column_mapping}
|
||||||
|
self.assertEqual(current.get(1), "Reference")
|
||||||
|
|
||||||
|
# Restore row 0 as the header (resolves meaningfully -> re-derived from labels).
|
||||||
|
set_header_index(doc.name, 0)
|
||||||
|
doc.reload()
|
||||||
|
self.assertEqual(doc.detected_header_index, 0)
|
||||||
|
restored = {c.maps_to: c.index for c in doc.column_mapping if c.maps_to != "Do not import"}
|
||||||
|
self.assertEqual(restored.get("Description"), 1)
|
||||||
|
|
||||||
|
|
||||||
test_hdfc_sample_statement_data = [
|
test_hdfc_sample_statement_data = [
|
||||||
["HDFC BANK Ltd. Page No .: 1 Statement of accounts", "", "", "", "", "", ""],
|
["HDFC BANK Ltd. Page No .: 1 Statement of accounts", "", "", "", "", "", ""],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import frappe
|
|||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.docstatus import DocStatus
|
from frappe.model.docstatus import DocStatus
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe.query_builder import Tuple
|
||||||
|
from frappe.query_builder.functions import Abs, Max, Sum
|
||||||
from frappe.utils import flt, getdate
|
from frappe.utils import flt, getdate
|
||||||
|
|
||||||
|
|
||||||
@@ -374,6 +376,7 @@ def unreconcile_transaction(transaction_name: str | int):
|
|||||||
Else, cancel the individual entries
|
Else, cancel the individual entries
|
||||||
"""
|
"""
|
||||||
transaction = frappe.get_doc("Bank Transaction", transaction_name)
|
transaction = frappe.get_doc("Bank Transaction", transaction_name)
|
||||||
|
transaction.check_permission("write")
|
||||||
|
|
||||||
vouchers_to_cancel = []
|
vouchers_to_cancel = []
|
||||||
|
|
||||||
@@ -401,6 +404,7 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
|
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
|
||||||
|
bank_transaction.check_permission("write")
|
||||||
|
|
||||||
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
|
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
|
||||||
for entry in bank_transaction.payment_entries:
|
for entry in bank_transaction.payment_entries:
|
||||||
@@ -476,30 +480,28 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries
|
|||||||
|
|
||||||
|
|
||||||
def get_related_bank_gl_entries(docs):
|
def get_related_bank_gl_entries(docs):
|
||||||
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
|
|
||||||
if not docs:
|
if not docs:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
result = frappe.db.sql(
|
gle = frappe.qb.DocType("GL Entry")
|
||||||
"""
|
ac = frappe.qb.DocType("Account")
|
||||||
SELECT
|
result = (
|
||||||
gle.voucher_type AS doctype,
|
frappe.qb.from_(gle)
|
||||||
gle.voucher_no AS docname,
|
.left_join(ac)
|
||||||
gle.account AS gl_account,
|
.on(ac.name == gle.account)
|
||||||
SUM(ABS(gle.credit_in_account_currency - gle.debit_in_account_currency)) AS amount
|
.select(
|
||||||
FROM
|
gle.voucher_type.as_("doctype"),
|
||||||
`tabGL Entry` gle
|
gle.voucher_no.as_("docname"),
|
||||||
LEFT JOIN
|
gle.account.as_("gl_account"),
|
||||||
`tabAccount` ac ON ac.name = gle.account
|
Sum(Abs(gle.credit_in_account_currency - gle.debit_in_account_currency)).as_("amount"),
|
||||||
WHERE
|
)
|
||||||
ac.account_type = 'Bank'
|
.where(
|
||||||
AND (gle.voucher_type, gle.voucher_no) IN %(docs)s
|
(ac.account_type == "Bank")
|
||||||
AND gle.is_cancelled = 0
|
& Tuple(gle.voucher_type, gle.voucher_no).isin([Tuple(vt, vn) for vt, vn in docs])
|
||||||
GROUP BY
|
& (gle.is_cancelled == 0)
|
||||||
gle.voucher_type, gle.voucher_no, gle.account
|
)
|
||||||
""",
|
.groupby(gle.voucher_type, gle.voucher_no, gle.account)
|
||||||
{"docs": docs},
|
.run(as_dict=True)
|
||||||
as_dict=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
entries = {}
|
entries = {}
|
||||||
@@ -521,31 +523,32 @@ def get_total_allocated_amount(docs):
|
|||||||
if not docs:
|
if not docs:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
|
# The original window query (ROW_NUMBER/FIRST_VALUE + rownum = 1) just collapses to one
|
||||||
result = frappe.db.sql(
|
# row per (account, payment_document, payment_entry) with the partition's allocation total
|
||||||
"""
|
# and most recent transaction date — i.e. a plain GROUP BY with SUM and MAX.
|
||||||
SELECT total, latest_date, gl_account, payment_document, payment_entry FROM (
|
btp = frappe.qb.DocType("Bank Transaction Payments")
|
||||||
SELECT
|
bt = frappe.qb.DocType("Bank Transaction")
|
||||||
ROW_NUMBER() OVER w AS rownum,
|
ba = frappe.qb.DocType("Bank Account")
|
||||||
SUM(btp.allocated_amount) OVER(PARTITION BY ba.account, btp.payment_document, btp.payment_entry) AS total,
|
|
||||||
FIRST_VALUE(bt.date) OVER w AS latest_date,
|
result = (
|
||||||
ba.account AS gl_account,
|
frappe.qb.from_(btp)
|
||||||
btp.payment_document,
|
.left_join(bt)
|
||||||
btp.payment_entry
|
.on(bt.name == btp.parent)
|
||||||
FROM
|
.left_join(ba)
|
||||||
`tabBank Transaction Payments` btp
|
.on(ba.name == bt.bank_account)
|
||||||
LEFT JOIN `tabBank Transaction` bt ON bt.name=btp.parent
|
.select(
|
||||||
LEFT JOIN `tabBank Account` ba ON ba.name=bt.bank_account
|
Sum(btp.allocated_amount).as_("total"),
|
||||||
WHERE
|
Max(bt.date).as_("latest_date"),
|
||||||
(btp.payment_document, btp.payment_entry) IN %(docs)s
|
ba.account.as_("gl_account"),
|
||||||
AND bt.docstatus = 1
|
btp.payment_document,
|
||||||
WINDOW w AS (PARTITION BY ba.account, btp.payment_document, btp.payment_entry ORDER BY bt.date DESC)
|
btp.payment_entry,
|
||||||
) temp
|
)
|
||||||
WHERE
|
.where(
|
||||||
rownum = 1
|
Tuple(btp.payment_document, btp.payment_entry).isin([Tuple(pd, pe) for pd, pe in docs])
|
||||||
""",
|
& (bt.docstatus == 1)
|
||||||
dict(docs=docs),
|
)
|
||||||
as_dict=True,
|
.groupby(ba.account, btp.payment_document, btp.payment_entry)
|
||||||
|
.run(as_dict=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
payment_allocation_details = {}
|
payment_allocation_details = {}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class TestBankTransaction(ERPNextTestSuite):
|
|||||||
from_date=bank_transaction.date,
|
from_date=bank_transaction.date,
|
||||||
to_date=utils.today(),
|
to_date=utils.today(),
|
||||||
)
|
)
|
||||||
self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
|
self.assertEqual(linked_payments[0]["party"], "Conrad Electronic")
|
||||||
|
|
||||||
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
|
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
|
||||||
def test_reconcile(self):
|
def test_reconcile(self):
|
||||||
@@ -70,10 +70,10 @@ class TestBankTransaction(ERPNextTestSuite):
|
|||||||
unallocated_amount = frappe.db.get_value(
|
unallocated_amount = frappe.db.get_value(
|
||||||
"Bank Transaction", bank_transaction.name, "unallocated_amount"
|
"Bank Transaction", bank_transaction.name, "unallocated_amount"
|
||||||
)
|
)
|
||||||
self.assertTrue(unallocated_amount == 0)
|
self.assertEqual(unallocated_amount, 0)
|
||||||
|
|
||||||
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
|
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
|
||||||
self.assertTrue(clearance_date is not None)
|
self.assertIsNot(clearance_date, None)
|
||||||
|
|
||||||
bank_transaction.reload()
|
bank_transaction.reload()
|
||||||
bank_transaction.cancel()
|
bank_transaction.cancel()
|
||||||
@@ -104,6 +104,36 @@ class TestBankTransaction(ERPNextTestSuite):
|
|||||||
self.assertEqual(bank_transaction.unallocated_amount, 1700)
|
self.assertEqual(bank_transaction.unallocated_amount, 1700)
|
||||||
self.assertEqual(bank_transaction.payment_entries, [])
|
self.assertEqual(bank_transaction.payment_entries, [])
|
||||||
|
|
||||||
|
# Amending a reconciled payment entry must not carry over its clearance date
|
||||||
|
def test_clearance_date_cleared_on_amend(self):
|
||||||
|
bank_transaction = frappe.get_doc(
|
||||||
|
"Bank Transaction",
|
||||||
|
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
|
||||||
|
)
|
||||||
|
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
|
||||||
|
vouchers = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"payment_doctype": "Payment Entry",
|
||||||
|
"payment_name": payment.name,
|
||||||
|
"amount": bank_transaction.unallocated_amount,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
reconcile_vouchers(bank_transaction.name, vouchers)
|
||||||
|
|
||||||
|
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
|
||||||
|
|
||||||
|
payment.reload()
|
||||||
|
payment.cancel()
|
||||||
|
|
||||||
|
amended = frappe.copy_doc(payment)
|
||||||
|
amended.amended_from = payment.name
|
||||||
|
amended.docstatus = 0
|
||||||
|
amended.insert()
|
||||||
|
|
||||||
|
self.assertFalse(amended.clearance_date)
|
||||||
|
|
||||||
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
|
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
|
||||||
def test_debit_credit_output(self):
|
def test_debit_credit_output(self):
|
||||||
bank_transaction = frappe.get_doc(
|
bank_transaction = frappe.get_doc(
|
||||||
@@ -178,9 +208,8 @@ class TestBankTransaction(ERPNextTestSuite):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0
|
frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertIsNot(
|
||||||
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date")
|
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date"), None
|
||||||
is not None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@if_lending_app_installed
|
@if_lending_app_installed
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
|||||||
|
|
||||||
class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.create_company()
|
self.company = "_Test Company"
|
||||||
self.create_customer()
|
self.customer = "_Test Customer"
|
||||||
self.clear_old_entries()
|
self.bank = "HDFC - _TC"
|
||||||
|
self.debit_to = "Debtors - _TC"
|
||||||
|
self.cash = "Cash - _TC"
|
||||||
bank_dt = qb.DocType("Bank")
|
bank_dt = qb.DocType("Bank")
|
||||||
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||||
self.create_bank_account()
|
self.create_bank_account()
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
|
|||||||
|
|
||||||
cur_node.save()
|
cur_node.save()
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist(methods=["POST"])
|
||||||
def build_tree(self):
|
def build_tree(self):
|
||||||
frappe.db.delete("Bisect Nodes")
|
frappe.db.delete("Bisect Nodes")
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ frappe.ui.form.on("Budget", {
|
|||||||
filters: {
|
filters: {
|
||||||
is_group: 0,
|
is_group: 0,
|
||||||
company: frm.doc.company,
|
company: frm.doc.company,
|
||||||
|
root_type: ["in", ["Income", "Expense"]],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -135,6 +136,9 @@ function set_total_budget_amount(frm) {
|
|||||||
function toggle_distribution_fields(frm) {
|
function toggle_distribution_fields(frm) {
|
||||||
const grid = frm.fields_dict.budget_distribution.grid;
|
const grid = frm.fields_dict.budget_distribution.grid;
|
||||||
|
|
||||||
|
frm.set_df_property("budget_distribution", "cannot_add_rows", true);
|
||||||
|
frm.set_df_property("budget_distribution", "cannot_delete_rows", true);
|
||||||
|
|
||||||
["amount", "percent"].forEach((field) => {
|
["amount", "percent"].forEach((field) => {
|
||||||
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
|
grid.update_docfield_property(field, "read_only", frm.doc.distribute_equally);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.query_builder.functions import Sum
|
from frappe.query_builder import Criterion
|
||||||
|
from frappe.query_builder.functions import Coalesce, Sum
|
||||||
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
|
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
|
||||||
from frappe.utils.data import get_first_day
|
from frappe.utils.data import get_first_day
|
||||||
|
from pypika.terms import ExistsCriterion
|
||||||
|
|
||||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||||
get_accounting_dimensions,
|
get_accounting_dimensions,
|
||||||
@@ -115,23 +117,26 @@ class Budget(Document):
|
|||||||
if not account:
|
if not account:
|
||||||
return
|
return
|
||||||
|
|
||||||
existing_budget = frappe.db.sql(
|
budget = frappe.qb.DocType("Budget")
|
||||||
f"""
|
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
|
||||||
SELECT name, account
|
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
|
||||||
FROM `tabBudget`
|
existing_budget = (
|
||||||
WHERE
|
frappe.qb.from_(budget)
|
||||||
docstatus < 2
|
.inner_join(fy_from)
|
||||||
AND company = %s
|
.on(fy_from.name == budget.from_fiscal_year)
|
||||||
AND {budget_against_field} = %s
|
.inner_join(fy_to)
|
||||||
AND account = %s
|
.on(fy_to.name == budget.to_fiscal_year)
|
||||||
AND name != %s
|
.select(budget.name, budget.account)
|
||||||
AND (
|
.where(
|
||||||
(SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
|
(budget.docstatus < 2)
|
||||||
AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
|
& (budget.company == self.company)
|
||||||
)
|
& (budget[budget_against_field] == budget_against)
|
||||||
""",
|
& (budget.account == account)
|
||||||
(self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date),
|
& (budget.name != self.name)
|
||||||
as_dict=True,
|
& (fy_from.year_start_date <= self.budget_end_date)
|
||||||
|
& (fy_to.year_end_date >= self.budget_start_date)
|
||||||
|
)
|
||||||
|
.run(as_dict=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing_budget:
|
if existing_budget:
|
||||||
@@ -353,8 +358,8 @@ class Budget(Document):
|
|||||||
if self.should_regenerate_budget_distribution():
|
if self.should_regenerate_budget_distribution():
|
||||||
return
|
return
|
||||||
|
|
||||||
total_amount = sum(d.amount for d in self.budget_distribution)
|
total_amount = sum(flt(d.amount) for d in self.budget_distribution)
|
||||||
total_percent = sum(d.percent for d in self.budget_distribution)
|
total_percent = sum(flt(d.percent) for d in self.budget_distribution)
|
||||||
|
|
||||||
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
|
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
@@ -381,17 +386,24 @@ def validate_expense_against_budget(params, expense_amount=0):
|
|||||||
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
|
posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
|
||||||
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
|
year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
|
||||||
|
|
||||||
budget_exists = frappe.db.sql(
|
budget = frappe.qb.DocType("Budget")
|
||||||
"""
|
fy_from = frappe.qb.DocType("Fiscal Year").as_("fy_from")
|
||||||
select name
|
fy_to = frappe.qb.DocType("Fiscal Year").as_("fy_to")
|
||||||
from `tabBudget`
|
budget_exists = (
|
||||||
where company = %s
|
frappe.qb.from_(budget)
|
||||||
and docstatus = 1
|
.inner_join(fy_from)
|
||||||
and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
|
.on(fy_from.name == budget.from_fiscal_year)
|
||||||
and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
|
.inner_join(fy_to)
|
||||||
limit 1
|
.on(fy_to.name == budget.to_fiscal_year)
|
||||||
""",
|
.select(budget.name)
|
||||||
(params.company, year_end_date, year_start_date),
|
.where(
|
||||||
|
(budget.company == params.company)
|
||||||
|
& (budget.docstatus == 1)
|
||||||
|
& (fy_from.year_start_date <= year_end_date)
|
||||||
|
& (fy_to.year_end_date >= year_start_date)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
.run()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not budget_exists:
|
if not budget_exists:
|
||||||
@@ -434,50 +446,52 @@ def validate_expense_against_budget(params, expense_amount=0):
|
|||||||
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
|
and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
|
||||||
):
|
):
|
||||||
doctype = dimension.get("document_type")
|
doctype = dimension.get("document_type")
|
||||||
|
params.is_tree = bool(frappe.get_cached_value("DocType", doctype, "is_tree"))
|
||||||
if frappe.get_cached_value("DocType", doctype, "is_tree"):
|
|
||||||
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
|
|
||||||
condition = f"""and exists(select name from `tab{doctype}`
|
|
||||||
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
|
|
||||||
params.is_tree = True
|
|
||||||
else:
|
|
||||||
condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
|
|
||||||
params.is_tree = False
|
|
||||||
|
|
||||||
params.budget_against_field = budget_against
|
params.budget_against_field = budget_against
|
||||||
params.budget_against_doctype = doctype
|
params.budget_against_doctype = doctype
|
||||||
|
|
||||||
budget_records = frappe.db.sql(
|
b = frappe.qb.DocType("Budget")
|
||||||
f"""
|
query = (
|
||||||
SELECT
|
frappe.qb.from_(b)
|
||||||
|
.select(
|
||||||
b.name,
|
b.name,
|
||||||
b.{budget_against} AS budget_against,
|
getattr(b, budget_against).as_("budget_against"),
|
||||||
b.budget_amount,
|
b.budget_amount,
|
||||||
b.from_fiscal_year,
|
b.from_fiscal_year,
|
||||||
b.to_fiscal_year,
|
b.to_fiscal_year,
|
||||||
b.budget_start_date,
|
b.budget_start_date,
|
||||||
b.budget_end_date,
|
b.budget_end_date,
|
||||||
IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
|
Coalesce(b.applicable_on_material_request, 0).as_("for_material_request"),
|
||||||
IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
|
Coalesce(b.applicable_on_purchase_order, 0).as_("for_purchase_order"),
|
||||||
IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
|
Coalesce(b.applicable_on_booking_actual_expenses, 0).as_("for_actual_expenses"),
|
||||||
b.action_if_annual_budget_exceeded,
|
b.action_if_annual_budget_exceeded,
|
||||||
b.action_if_accumulated_monthly_budget_exceeded,
|
b.action_if_accumulated_monthly_budget_exceeded,
|
||||||
b.action_if_annual_budget_exceeded_on_mr,
|
b.action_if_annual_budget_exceeded_on_mr,
|
||||||
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
|
b.action_if_accumulated_monthly_budget_exceeded_on_mr,
|
||||||
b.action_if_annual_budget_exceeded_on_po,
|
b.action_if_annual_budget_exceeded_on_po,
|
||||||
b.action_if_accumulated_monthly_budget_exceeded_on_po
|
b.action_if_accumulated_monthly_budget_exceeded_on_po,
|
||||||
FROM
|
)
|
||||||
`tabBudget` b
|
.where(b.company == params.company)
|
||||||
WHERE
|
.where(b.docstatus == 1)
|
||||||
b.company = %s
|
.where(b.budget_start_date <= params.posting_date)
|
||||||
AND b.docstatus = 1
|
.where(b.budget_end_date >= params.posting_date)
|
||||||
AND %s BETWEEN b.budget_start_date AND b.budget_end_date
|
.where(b.account == params.account)
|
||||||
AND b.account = %s
|
)
|
||||||
{condition}
|
|
||||||
""",
|
if params.is_tree:
|
||||||
(params.company, params.posting_date, params.account),
|
lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
|
||||||
as_dict=True,
|
dim = frappe.qb.DocType(doctype)
|
||||||
) # nosec
|
query = query.where(
|
||||||
|
ExistsCriterion(
|
||||||
|
frappe.qb.from_(dim)
|
||||||
|
.select(dim.name)
|
||||||
|
.where((dim.lft <= lft) & (dim.rgt >= rgt) & (dim.name == getattr(b, budget_against)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.where(getattr(b, budget_against) == params.get(budget_against))
|
||||||
|
|
||||||
|
budget_records = query.run(as_dict=True)
|
||||||
|
|
||||||
if budget_records:
|
if budget_records:
|
||||||
validate_budget_records(params, budget_records, expense_amount)
|
validate_budget_records(params, budget_records, expense_amount)
|
||||||
@@ -674,15 +688,27 @@ def get_actions(params, budget):
|
|||||||
|
|
||||||
def get_requested_amount(params):
|
def get_requested_amount(params):
|
||||||
item_code = params.get("item_code")
|
item_code = params.get("item_code")
|
||||||
condition = get_other_condition(params, "Material Request")
|
|
||||||
|
|
||||||
data = frappe.db.sql(
|
child = frappe.qb.DocType("Material Request Item")
|
||||||
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
|
parent = frappe.qb.DocType("Material Request")
|
||||||
from `tabMaterial Request Item` child, `tabMaterial Request` parent where parent.name = child.parent and
|
|
||||||
child.item_code = %s and parent.docstatus = 1 and child.stock_qty > child.ordered_qty and {} and
|
data = (
|
||||||
parent.material_request_type = 'Purchase' and parent.status != 'Stopped'""".format(condition),
|
frappe.qb.from_(child)
|
||||||
item_code,
|
.join(parent)
|
||||||
as_list=1,
|
.on(parent.name == child.parent)
|
||||||
|
.select(
|
||||||
|
# rate inside the aggregate: Sum(qty * rate) is the correct requested amount and is PG-valid
|
||||||
|
Coalesce(Sum((child.stock_qty - child.ordered_qty) * child.rate), 0).as_("amount")
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(child.item_code == item_code)
|
||||||
|
& (parent.docstatus == 1)
|
||||||
|
& (child.stock_qty > child.ordered_qty)
|
||||||
|
& Criterion.all(get_other_condition(params, child, parent, "Material Request"))
|
||||||
|
& (parent.material_request_type == "Purchase")
|
||||||
|
& (parent.status != "Stopped")
|
||||||
|
)
|
||||||
|
.run(as_list=1)
|
||||||
)
|
)
|
||||||
|
|
||||||
return data[0][0] if data else 0
|
return data[0][0] if data else 0
|
||||||
@@ -690,35 +716,43 @@ def get_requested_amount(params):
|
|||||||
|
|
||||||
def get_ordered_amount(params):
|
def get_ordered_amount(params):
|
||||||
item_code = params.get("item_code")
|
item_code = params.get("item_code")
|
||||||
condition = get_other_condition(params, "Purchase Order")
|
|
||||||
|
|
||||||
data = frappe.db.sql(
|
child = frappe.qb.DocType("Purchase Order Item")
|
||||||
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
|
parent = frappe.qb.DocType("Purchase Order")
|
||||||
from `tabPurchase Order Item` child, `tabPurchase Order` parent where
|
|
||||||
parent.name = child.parent and child.item_code = %s and parent.docstatus = 1 and child.amount > child.billed_amt
|
data = (
|
||||||
and parent.status != 'Closed' and {condition}""",
|
frappe.qb.from_(child)
|
||||||
item_code,
|
.join(parent)
|
||||||
as_list=1,
|
.on(parent.name == child.parent)
|
||||||
|
.select(Coalesce(Sum(child.amount - child.billed_amt), 0).as_("amount"))
|
||||||
|
.where(
|
||||||
|
(child.item_code == item_code)
|
||||||
|
& (parent.docstatus == 1)
|
||||||
|
& (child.amount > child.billed_amt)
|
||||||
|
& (parent.status != "Closed")
|
||||||
|
& Criterion.all(get_other_condition(params, child, parent, "Purchase Order"))
|
||||||
|
)
|
||||||
|
.run(as_list=1)
|
||||||
)
|
)
|
||||||
|
|
||||||
return data[0][0] if data else 0
|
return data[0][0] if data else 0
|
||||||
|
|
||||||
|
|
||||||
def get_other_condition(params, for_doc):
|
def get_other_condition(params, child, parent, for_doc):
|
||||||
condition = f"expense_account = '{params.expense_account}'"
|
conditions = [child.expense_account == params.expense_account]
|
||||||
budget_against_field = params.get("budget_against_field")
|
budget_against_field = params.get("budget_against_field")
|
||||||
|
|
||||||
if budget_against_field and params.get(budget_against_field):
|
if budget_against_field and params.get(budget_against_field):
|
||||||
condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'"
|
conditions.append(child[budget_against_field] == params.get(budget_against_field))
|
||||||
|
|
||||||
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
||||||
|
|
||||||
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
||||||
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
||||||
|
|
||||||
condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
|
conditions.append(parent[date_field][str(start_date) : str(end_date)])
|
||||||
|
|
||||||
return condition
|
return conditions
|
||||||
|
|
||||||
|
|
||||||
def get_actual_expense(params):
|
def get_actual_expense(params):
|
||||||
@@ -726,11 +760,19 @@ def get_actual_expense(params):
|
|||||||
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
|
params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
|
||||||
|
|
||||||
budget_against_field = params.get("budget_against_field")
|
budget_against_field = params.get("budget_against_field")
|
||||||
condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
|
|
||||||
|
|
||||||
date_condition = (
|
gle = frappe.qb.DocType("GL Entry")
|
||||||
f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
|
|
||||||
)
|
conditions = [
|
||||||
|
gle.is_cancelled == 0,
|
||||||
|
gle.account == params.get("account"),
|
||||||
|
gle.posting_date[str(params.budget_start_date) : str(params.budget_end_date)],
|
||||||
|
gle.company == params.get("company"),
|
||||||
|
gle.docstatus == 1,
|
||||||
|
]
|
||||||
|
|
||||||
|
if params.get("month_end_date"):
|
||||||
|
conditions.append(gle.posting_date <= params.get("month_end_date"))
|
||||||
|
|
||||||
if params.is_tree:
|
if params.is_tree:
|
||||||
lft_rgt = frappe.db.get_value(
|
lft_rgt = frappe.db.get_value(
|
||||||
@@ -738,35 +780,27 @@ def get_actual_expense(params):
|
|||||||
)
|
)
|
||||||
params.update(lft_rgt)
|
params.update(lft_rgt)
|
||||||
|
|
||||||
condition2 = f"""
|
tree = frappe.qb.DocType(params.budget_against_doctype)
|
||||||
and exists(
|
conditions.append(
|
||||||
select name from `tab{params.budget_against_doctype}`
|
ExistsCriterion(
|
||||||
where lft >= %(lft)s and rgt <= %(rgt)s
|
frappe.qb.from_(tree)
|
||||||
and name = gle.{budget_against_field}
|
.select(tree.name)
|
||||||
|
.where(
|
||||||
|
(tree.lft >= params.get("lft"))
|
||||||
|
& (tree.rgt <= params.get("rgt"))
|
||||||
|
& (tree.name == gle[budget_against_field])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
"""
|
)
|
||||||
else:
|
else:
|
||||||
condition2 = f"""
|
conditions.append(gle[budget_against_field] == params.get(budget_against_field))
|
||||||
and gle.{budget_against_field} = %({budget_against_field})s
|
|
||||||
"""
|
|
||||||
|
|
||||||
amount = flt(
|
amount = flt(
|
||||||
frappe.db.sql(
|
frappe.qb.from_(gle)
|
||||||
f"""
|
.select(Sum(gle.debit) - Sum(gle.credit))
|
||||||
select sum(gle.debit) - sum(gle.credit)
|
.where(Criterion.all(conditions))
|
||||||
from `tabGL Entry` gle
|
.run()[0][0]
|
||||||
where
|
)
|
||||||
is_cancelled = 0
|
|
||||||
and gle.account = %(account)s
|
|
||||||
{condition1}
|
|
||||||
{date_condition}
|
|
||||||
and gle.company = %(company)s
|
|
||||||
and gle.docstatus = 1
|
|
||||||
{condition2}
|
|
||||||
""",
|
|
||||||
params,
|
|
||||||
)[0][0]
|
|
||||||
) # nosec
|
|
||||||
|
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Start Date",
|
"label": "Start Date",
|
||||||
"read_only": 1,
|
"read_only": 1,
|
||||||
|
"reqd": 1,
|
||||||
"search_index": 1
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -25,26 +26,29 @@
|
|||||||
"fieldtype": "Date",
|
"fieldtype": "Date",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "End Date",
|
"label": "End Date",
|
||||||
"read_only": 1
|
"read_only": 1,
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "amount",
|
"fieldname": "amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Amount"
|
"label": "Amount",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "percent",
|
"fieldname": "percent",
|
||||||
"fieldtype": "Percent",
|
"fieldtype": "Percent",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Percent"
|
"label": "Percent",
|
||||||
|
"reqd": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-11-03 13:18:28.398198",
|
"modified": "2026-06-18 11:23:17.669733",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Budget Distribution",
|
"name": "Budget Distribution",
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ class BudgetDistribution(Document):
|
|||||||
from frappe.types import DF
|
from frappe.types import DF
|
||||||
|
|
||||||
amount: DF.Currency
|
amount: DF.Currency
|
||||||
end_date: DF.Date | None
|
end_date: DF.Date
|
||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
parenttype: DF.Data
|
parenttype: DF.Data
|
||||||
percent: DF.Percent
|
percent: DF.Percent
|
||||||
start_date: DF.Date | None
|
start_date: DF.Date
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe.query_builder.functions import Sum
|
||||||
from frappe.utils import flt
|
from frappe.utils import flt
|
||||||
|
|
||||||
|
|
||||||
@@ -43,13 +44,17 @@ class CashierClosing(Document):
|
|||||||
self.make_calculations()
|
self.make_calculations()
|
||||||
|
|
||||||
def get_outstanding(self):
|
def get_outstanding(self):
|
||||||
values = frappe.db.sql(
|
si = frappe.qb.DocType("Sales Invoice")
|
||||||
"""
|
values = (
|
||||||
select sum(outstanding_amount)
|
frappe.qb.from_(si)
|
||||||
from `tabSales Invoice`
|
.select(Sum(si.outstanding_amount))
|
||||||
where posting_date=%s and posting_time>=%s and posting_time<=%s and owner=%s
|
.where(
|
||||||
""",
|
(si.posting_date == self.date)
|
||||||
(self.date, self.from_time, self.time, self.user),
|
& (si.posting_time >= self.from_time)
|
||||||
|
& (si.posting_time <= self.time)
|
||||||
|
& (si.owner == self.user)
|
||||||
|
)
|
||||||
|
.run()
|
||||||
)
|
)
|
||||||
self.outstanding_amount = flt(values[0][0] if values else 0)
|
self.outstanding_amount = flt(values[0][0] if values else 0)
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ def validate_company(company: str):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def import_coa(file_name: str, company: str):
|
def import_coa(file_name: str, company: str):
|
||||||
|
frappe.only_for("Accounts Manager")
|
||||||
|
|
||||||
# delete existing data for accounts
|
# delete existing data for accounts
|
||||||
|
frappe.has_permission("Company", "write", company, throw=True)
|
||||||
unset_existing_data(company)
|
unset_existing_data(company)
|
||||||
|
|
||||||
# create accounts
|
# create accounts
|
||||||
@@ -453,6 +456,7 @@ def unset_existing_data(company):
|
|||||||
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
|
||||||
linked = [{"fieldname": name} for name in fieldnames]
|
linked = [{"fieldname": name} for name in fieldnames]
|
||||||
update_values = {d.get("fieldname"): "" for d in linked}
|
update_values = {d.get("fieldname"): "" for d in linked}
|
||||||
|
|
||||||
frappe.db.set_value("Company", company, update_values, update_values)
|
frappe.db.set_value("Company", company, update_values, update_values)
|
||||||
|
|
||||||
# remove accounts data from various doctypes
|
# remove accounts data from various doctypes
|
||||||
@@ -464,8 +468,7 @@ def unset_existing_data(company):
|
|||||||
"Sales Taxes and Charges Template",
|
"Sales Taxes and Charges Template",
|
||||||
"Purchase Taxes and Charges Template",
|
"Purchase Taxes and Charges Template",
|
||||||
]:
|
]:
|
||||||
dt = frappe.qb.DocType(doctype)
|
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
|
||||||
frappe.qb.from_(dt).where(dt.company == company).delete().run()
|
|
||||||
|
|
||||||
|
|
||||||
def set_default_accounts(company):
|
def set_default_accounts(company):
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
|
|||||||
frappe.ui.form.on("Cheque Print Template", {
|
frappe.ui.form.on("Cheque Print Template", {
|
||||||
refresh: function (frm) {
|
refresh: function (frm) {
|
||||||
if (!frm.doc.__islocal) {
|
if (!frm.doc.__islocal) {
|
||||||
frm.add_custom_button(
|
if (frappe.user.has_role("System Manager")) {
|
||||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
frm.add_custom_button(
|
||||||
function () {
|
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||||
erpnext.cheque_print.view_cheque_print(frm);
|
function () {
|
||||||
}
|
erpnext.cheque_print.view_cheque_print(frm);
|
||||||
).addClass("btn-primary");
|
}
|
||||||
|
).addClass("btn-primary");
|
||||||
|
}
|
||||||
|
|
||||||
$(frm.fields_dict.cheque_print_preview.wrapper).empty();
|
$(frm.fields_dict.cheque_print_preview.wrapper).empty();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"autoname": "field:bank_name",
|
"autoname": "field:bank_name",
|
||||||
"creation": "2016-05-04 14:35:00.402544",
|
"creation": "2016-05-04 14:35:00.402544",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -294,7 +295,7 @@
|
|||||||
],
|
],
|
||||||
"links": [],
|
"links": [],
|
||||||
"max_attachments": 1,
|
"max_attachments": 1,
|
||||||
"modified": "2024-03-27 13:06:44.654989",
|
"modified": "2026-06-08 12:10:35.829531",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Cheque Print Template",
|
"name": "Cheque Print Template",
|
||||||
@@ -325,19 +326,17 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "Accounts User",
|
"role": "Accounts User",
|
||||||
"share": 1,
|
"share": 1
|
||||||
"write": 1
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_or_update_cheque_print_format(template_name: str):
|
def create_or_update_cheque_print_format(template_name: str):
|
||||||
|
frappe.only_for("System Manager")
|
||||||
|
|
||||||
if not frappe.db.exists("Print Format", template_name):
|
if not frappe.db.exists("Print Format", template_name):
|
||||||
cheque_print = frappe.new_doc("Print Format")
|
cheque_print = frappe.new_doc("Print Format")
|
||||||
cheque_print.update(
|
cheque_print.update(
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ class CostCenter(NestedSet):
|
|||||||
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
|
return frappe.db.get_value("GL Entry", {"cost_center": self.name})
|
||||||
|
|
||||||
def check_if_child_exists(self):
|
def check_if_child_exists(self):
|
||||||
return frappe.db.sql(
|
return frappe.get_all(
|
||||||
"select name from `tabCost Center` where \
|
"Cost Center",
|
||||||
parent_cost_center = %s and docstatus != 2",
|
filters={"parent_cost_center": self.name, "docstatus": ["!=", 2]},
|
||||||
self.name,
|
pluck="name",
|
||||||
)
|
)
|
||||||
|
|
||||||
def if_allocation_exists_against_cost_center(self):
|
def if_allocation_exists_against_cost_center(self):
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ class TestCostCenterAllocation(ERPNextTestSuite):
|
|||||||
self.assertTrue(gl_entries)
|
self.assertTrue(gl_entries)
|
||||||
|
|
||||||
for gle in gl_entries:
|
for gle in gl_entries:
|
||||||
self.assertTrue(gle.cost_center in expected_values)
|
self.assertIn(gle.cost_center, expected_values)
|
||||||
self.assertEqual(gle.debit, 0)
|
self.assertEqual(gle.debit, 0)
|
||||||
self.assertEqual(gle.credit, expected_values[gle.cost_center])
|
self.assertEqual(gle.credit, expected_values[gle.cost_center])
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", {
|
|||||||
},
|
},
|
||||||
callback: function (r) {
|
callback: function (r) {
|
||||||
if (r && r.message) {
|
if (r && r.message) {
|
||||||
|
let result = [],
|
||||||
|
params = {};
|
||||||
if (frm.doc.service_provider == "exchangerate.host") {
|
if (frm.doc.service_provider == "exchangerate.host") {
|
||||||
let result = ["result"];
|
result = ["result"];
|
||||||
let params = {
|
params = {
|
||||||
date: "{transaction_date}",
|
date: "{transaction_date}",
|
||||||
from: "{from_currency}",
|
from: "{from_currency}",
|
||||||
to: "{to_currency}",
|
to: "{to_currency}",
|
||||||
};
|
};
|
||||||
add_param(frm, r.message, params, result);
|
|
||||||
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
|
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
|
||||||
let result = ["rates", "{to_currency}"];
|
result = ["rates", "{to_currency}"];
|
||||||
let params = {
|
params = {
|
||||||
base: "{from_currency}",
|
base: "{from_currency}",
|
||||||
symbols: "{to_currency}",
|
symbols: "{to_currency}",
|
||||||
};
|
};
|
||||||
add_param(frm, r.message, params, result);
|
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
|
||||||
|
result = ["rate"];
|
||||||
|
params = {
|
||||||
|
date: "{transaction_date}",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
add_param(frm, r.message, params, result);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
"fieldname": "service_provider",
|
"fieldname": "service_provider",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Service Provider",
|
"label": "Service Provider",
|
||||||
"options": "frankfurter.dev\nexchangerate.host\nCustom",
|
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,11 +101,10 @@
|
|||||||
"label": "Use HTTP Protocol"
|
"label": "Use HTTP Protocol"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hide_toolbar": 0,
|
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-03-16 13:28:21.075743",
|
"modified": "2026-06-15 11:25:55.873110",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Currency Exchange Settings",
|
"name": "Currency Exchange Settings",
|
||||||
@@ -122,24 +121,11 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
|
||||||
"print": 1,
|
|
||||||
"read": 1,
|
|
||||||
"role": "Accounts Manager",
|
|
||||||
"share": 1,
|
|
||||||
"write": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"create": 1,
|
|
||||||
"delete": 1,
|
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"role": "Accounts User",
|
"role": "Accounts User",
|
||||||
"share": 1,
|
"share": 1
|
||||||
"write": 1
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"row_format": "Dynamic",
|
"row_format": "Dynamic",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
|
|||||||
disabled: DF.Check
|
disabled: DF.Check
|
||||||
req_params: DF.Table[CurrencyExchangeSettingsDetails]
|
req_params: DF.Table[CurrencyExchangeSettingsDetails]
|
||||||
result_key: DF.Table[CurrencyExchangeSettingsResult]
|
result_key: DF.Table[CurrencyExchangeSettingsResult]
|
||||||
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
|
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
|
||||||
url: DF.Data | None
|
url: DF.Data | None
|
||||||
use_http: DF.Check
|
use_http: DF.Check
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
@@ -70,6 +70,14 @@ class CurrencyExchangeSettings(Document):
|
|||||||
self.append("req_params", {"key": "base", "value": "{from_currency}"})
|
self.append("req_params", {"key": "base", "value": "{from_currency}"})
|
||||||
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
|
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
|
||||||
|
|
||||||
|
elif self.service_provider == "frankfurter.dev - v2":
|
||||||
|
self.set("result_key", [])
|
||||||
|
self.set("req_params", [])
|
||||||
|
|
||||||
|
self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
|
||||||
|
self.append("result_key", {"key": "rate"})
|
||||||
|
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
|
||||||
|
|
||||||
def validate_parameters(self):
|
def validate_parameters(self):
|
||||||
params = {}
|
params = {}
|
||||||
for row in self.req_params:
|
for row in self.req_params:
|
||||||
@@ -105,13 +113,20 @@ class CurrencyExchangeSettings(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
|
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
|
||||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
|
if service_provider and service_provider in [
|
||||||
|
"exchangerate.host",
|
||||||
|
"frankfurter.dev",
|
||||||
|
"frankfurter.app",
|
||||||
|
"frankfurter.dev - v2",
|
||||||
|
]:
|
||||||
if service_provider == "exchangerate.host":
|
if service_provider == "exchangerate.host":
|
||||||
api = "api.exchangerate.host/convert"
|
api = "api.exchangerate.host/convert"
|
||||||
elif service_provider == "frankfurter.app":
|
elif service_provider == "frankfurter.app":
|
||||||
api = "api.frankfurter.app/{transaction_date}"
|
api = "api.frankfurter.app/{transaction_date}"
|
||||||
elif service_provider == "frankfurter.dev":
|
elif service_provider == "frankfurter.dev":
|
||||||
api = "api.frankfurter.dev/v1/{transaction_date}"
|
api = "api.frankfurter.dev/v1/{transaction_date}"
|
||||||
|
elif service_provider == "frankfurter.dev - v2":
|
||||||
|
api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
|
||||||
|
|
||||||
protocol = "https://"
|
protocol = "https://"
|
||||||
if use_http:
|
if use_http:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ frappe.ui.form.on("Dunning", {
|
|||||||
if (frm.doc.docstatus === 0) {
|
if (frm.doc.docstatus === 0) {
|
||||||
frm.add_custom_button(__("Fetch Overdue Payments"), () => {
|
frm.add_custom_button(__("Fetch Overdue Payments"), () => {
|
||||||
erpnext.utils.map_current_doc({
|
erpnext.utils.map_current_doc({
|
||||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
|
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
|
||||||
source_doctype: "Sales Invoice",
|
source_doctype: "Sales Invoice",
|
||||||
date_field: "due_date",
|
date_field: "due_date",
|
||||||
target: frm,
|
target: frm,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from frappe.utils import add_days, nowdate, today
|
|||||||
|
|
||||||
from erpnext import get_default_cost_center
|
from erpnext import get_default_cost_center
|
||||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
|
||||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
from erpnext.accounts.doctype.sales_invoice.mapper import (
|
||||||
create_dunning as create_dunning_from_sales_invoice,
|
create_dunning as create_dunning_from_sales_invoice,
|
||||||
)
|
)
|
||||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
||||||
@@ -73,7 +73,7 @@ class TestDunning(ERPNextTestSuite):
|
|||||||
dunning = create_dunning_from_sales_invoice(si1.name)
|
dunning = create_dunning_from_sales_invoice(si1.name)
|
||||||
dunning.overdue_payments = []
|
dunning.overdue_payments = []
|
||||||
|
|
||||||
method = "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning"
|
method = "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning"
|
||||||
updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning)
|
updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning)
|
||||||
|
|
||||||
self.assertEqual(len(updated_dunning.overdue_payments), 2)
|
self.assertEqual(len(updated_dunning.overdue_payments), 2)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from frappe import _, qb
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.model.meta import get_field_precision
|
from frappe.model.meta import get_field_precision
|
||||||
from frappe.query_builder import Criterion, Order
|
from frappe.query_builder import Criterion, Order
|
||||||
from frappe.query_builder.functions import NullIf, Sum
|
from frappe.query_builder.functions import Max, NullIf, Sum
|
||||||
from frappe.utils import flt, get_link_to_form
|
from frappe.utils import flt, get_link_to_form
|
||||||
|
|
||||||
import erpnext
|
import erpnext
|
||||||
@@ -188,12 +188,18 @@ class ExchangeRateRevaluation(Document):
|
|||||||
accounts = [x[0] for x in res]
|
accounts = [x[0] for x in res]
|
||||||
|
|
||||||
if accounts:
|
if accounts:
|
||||||
having_clause = (qb.Field("balance") != qb.Field("balance_in_account_currency")) & (
|
|
||||||
(qb.Field("balance_in_account_currency") != 0) | (qb.Field("balance") != 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
gle = qb.DocType("GL Entry")
|
gle = qb.DocType("GL Entry")
|
||||||
|
|
||||||
|
# balance expressions reused in both SELECT and HAVING; postgres can't reference a
|
||||||
|
# SELECT alias inside HAVING, so the aggregate expression must be repeated there.
|
||||||
|
balance = Sum(gle.debit) - Sum(gle.credit)
|
||||||
|
balance_in_account_currency = Sum(gle.debit_in_account_currency) - Sum(
|
||||||
|
gle.credit_in_account_currency
|
||||||
|
)
|
||||||
|
having_clause = (balance != balance_in_account_currency) & (
|
||||||
|
(balance_in_account_currency != 0) | (balance != 0)
|
||||||
|
)
|
||||||
|
|
||||||
# conditions
|
# conditions
|
||||||
conditions = []
|
conditions = []
|
||||||
conditions.append(gle.account.isin(accounts))
|
conditions.append(gle.account.isin(accounts))
|
||||||
@@ -209,17 +215,15 @@ class ExchangeRateRevaluation(Document):
|
|||||||
qb.from_(gle)
|
qb.from_(gle)
|
||||||
.select(
|
.select(
|
||||||
gle.account,
|
gle.account,
|
||||||
gle.party_type,
|
# grouped by NullIf(party_type/party, ""); the bare columns + account_currency are
|
||||||
gle.party,
|
# constant per group -> Max() keeps the GROUP BY valid on postgres with the same value.
|
||||||
gle.account_currency,
|
Max(gle.party_type).as_("party_type"),
|
||||||
(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency)).as_(
|
Max(gle.party).as_("party"),
|
||||||
"balance_in_account_currency"
|
Max(gle.account_currency).as_("account_currency"),
|
||||||
),
|
balance_in_account_currency.as_("balance_in_account_currency"),
|
||||||
(Sum(gle.debit) - Sum(gle.credit)).as_("balance"),
|
balance.as_("balance"),
|
||||||
(Sum(gle.debit) - Sum(gle.credit) == 0)
|
# zero_balance is recomputed in Python below (after rounding), so the SQL value is
|
||||||
^ (Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency) == 0).as_(
|
# unused -- dropped (it used MySQL's XOR operator, which postgres lacks).
|
||||||
"zero_balance"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.where(Criterion.all(conditions))
|
.where(Criterion.all(conditions))
|
||||||
.groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""))
|
.groupby(gle.account, NullIf(gle.party_type, ""), NullIf(gle.party, ""))
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
|||||||
|
|
||||||
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.create_company()
|
self.company = "_Test Company"
|
||||||
self.create_usd_receivable_account()
|
self.item = "_Test Item"
|
||||||
self.create_item()
|
self.customer = "_Test Customer"
|
||||||
self.create_customer()
|
self.cost_center = "Main - _TC"
|
||||||
self.clear_old_entries()
|
self.debtors_usd = "_Test Receivable USD - _TC"
|
||||||
self.set_system_and_company_settings()
|
self.set_system_and_company_settings()
|
||||||
|
|
||||||
def set_system_and_company_settings(self):
|
def set_system_and_company_settings(self):
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ class CalculationFormulaValidator(Validator):
|
|||||||
"sqrt": lambda x: x**0.5,
|
"sqrt": lambda x: x**0.5,
|
||||||
"pow": pow,
|
"pow": pow,
|
||||||
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
||||||
"floor": lambda x: int(x),
|
"floor": int,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -72,10 +72,8 @@ class FiscalYear(Document):
|
|||||||
|
|
||||||
if existing_fiscal_years:
|
if existing_fiscal_years:
|
||||||
for existing in existing_fiscal_years:
|
for existing in existing_fiscal_years:
|
||||||
company_for_existing = frappe.db.sql_list(
|
company_for_existing = frappe.get_all(
|
||||||
"""select company from `tabFiscal Year Company`
|
"Fiscal Year Company", filters={"parent": existing.name}, pluck="company"
|
||||||
where parent=%s""",
|
|
||||||
existing.name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
overlap = False
|
overlap = False
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from frappe import _
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.model.meta import get_field_precision
|
from frappe.model.meta import get_field_precision
|
||||||
from frappe.model.naming import set_name_from_naming_options
|
from frappe.model.naming import set_name_from_naming_options
|
||||||
|
from frappe.query_builder.functions import Sum
|
||||||
from frappe.utils import create_batch, flt, fmt_money, now
|
from frappe.utils import create_batch, flt, fmt_money, now
|
||||||
|
|
||||||
import erpnext
|
import erpnext
|
||||||
@@ -331,10 +332,12 @@ def validate_balance_type(account, adv_adj=False):
|
|||||||
if not adv_adj and account:
|
if not adv_adj and account:
|
||||||
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
|
balance_must_be = frappe.get_cached_value("Account", account, "balance_must_be")
|
||||||
if balance_must_be:
|
if balance_must_be:
|
||||||
balance = frappe.db.sql(
|
gle = frappe.qb.DocType("GL Entry")
|
||||||
"""select sum(debit) - sum(credit)
|
balance = (
|
||||||
from `tabGL Entry` where is_cancelled = 0 and account = %s""",
|
frappe.qb.from_(gle)
|
||||||
account,
|
.select(Sum(gle.debit) - Sum(gle.credit))
|
||||||
|
.where((gle.is_cancelled == 0) & (gle.account == account))
|
||||||
|
.run()
|
||||||
)[0][0]
|
)[0][0]
|
||||||
|
|
||||||
if (balance_must_be == "Debit" and flt(balance) < 0) or (
|
if (balance_must_be == "Debit" and flt(balance) < 0) or (
|
||||||
@@ -348,44 +351,48 @@ def validate_balance_type(account, adv_adj=False):
|
|||||||
def update_outstanding_amt(
|
def update_outstanding_amt(
|
||||||
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
|
account, party_type, party, against_voucher_type, against_voucher, on_cancel=False
|
||||||
):
|
):
|
||||||
|
gle = frappe.qb.DocType("GL Entry")
|
||||||
|
|
||||||
|
conditions = (
|
||||||
|
(gle.against_voucher_type == against_voucher_type)
|
||||||
|
& (gle.against_voucher == against_voucher)
|
||||||
|
& (gle.voucher_type != "Invoice Discounting")
|
||||||
|
)
|
||||||
if party_type and party:
|
if party_type and party:
|
||||||
party_condition = " and party_type={} and party={}".format(
|
conditions &= (gle.party_type == party_type) & (gle.party == party)
|
||||||
frappe.db.escape(party_type), frappe.db.escape(party)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
party_condition = ""
|
|
||||||
|
|
||||||
if against_voucher_type == "Sales Invoice":
|
if against_voucher_type == "Sales Invoice":
|
||||||
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
|
party_account = frappe.get_cached_value(against_voucher_type, against_voucher, "debit_to")
|
||||||
account_condition = f"and account in ({frappe.db.escape(account)}, {frappe.db.escape(party_account)})"
|
conditions &= gle.account.isin([account, party_account])
|
||||||
else:
|
else:
|
||||||
account_condition = f" and account = {frappe.db.escape(account)}"
|
conditions &= gle.account == account
|
||||||
|
|
||||||
# get final outstanding amt
|
# get final outstanding amt
|
||||||
bal = flt(
|
bal = flt(
|
||||||
frappe.db.sql(
|
frappe.qb.from_(gle)
|
||||||
f"""
|
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
.where(conditions)
|
||||||
from `tabGL Entry`
|
.run()[0][0]
|
||||||
where against_voucher_type=%s and against_voucher=%s
|
|
||||||
and voucher_type != 'Invoice Discounting'
|
|
||||||
{party_condition} {account_condition}""",
|
|
||||||
(against_voucher_type, against_voucher),
|
|
||||||
)[0][0]
|
|
||||||
or 0.0
|
or 0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
if against_voucher_type == "Purchase Invoice":
|
if against_voucher_type == "Purchase Invoice":
|
||||||
bal = -bal
|
bal = -bal
|
||||||
elif against_voucher_type == "Journal Entry":
|
elif against_voucher_type == "Journal Entry":
|
||||||
|
je_conditions = (
|
||||||
|
(gle.voucher_type == "Journal Entry")
|
||||||
|
& (gle.voucher_no == against_voucher)
|
||||||
|
& (gle.account == account)
|
||||||
|
& (gle.against_voucher.isnull() | (gle.against_voucher == ""))
|
||||||
|
)
|
||||||
|
if party_type and party:
|
||||||
|
je_conditions &= (gle.party_type == party_type) & (gle.party == party)
|
||||||
|
|
||||||
against_voucher_amount = flt(
|
against_voucher_amount = flt(
|
||||||
frappe.db.sql(
|
frappe.qb.from_(gle)
|
||||||
f"""
|
.select(Sum(gle.debit_in_account_currency) - Sum(gle.credit_in_account_currency))
|
||||||
select sum(debit_in_account_currency) - sum(credit_in_account_currency)
|
.where(je_conditions)
|
||||||
from `tabGL Entry` where voucher_type = 'Journal Entry' and voucher_no = %s
|
.run()[0][0]
|
||||||
and account = %s and (against_voucher is null or against_voucher='') {party_condition}""",
|
|
||||||
(against_voucher, account),
|
|
||||||
)[0][0]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not against_voucher_amount:
|
if not against_voucher_amount:
|
||||||
@@ -480,10 +487,14 @@ def rename_temporarily_named_docs(doctype):
|
|||||||
oldname = doc.name
|
oldname = doc.name
|
||||||
set_name_from_naming_options(autoname, doc)
|
set_name_from_naming_options(autoname, doc)
|
||||||
newname = doc.name
|
newname = doc.name
|
||||||
frappe.db.sql(
|
dt = frappe.qb.DocType(doctype)
|
||||||
f"UPDATE `tab{doctype}` SET name = %s, to_rename = 0, modified = %s where name = %s",
|
(
|
||||||
(newname, now(), oldname),
|
frappe.qb.update(dt)
|
||||||
)
|
.set(dt.name, newname)
|
||||||
|
.set(dt.to_rename, 0)
|
||||||
|
.set(dt.modified, now())
|
||||||
|
.where(dt.name == oldname)
|
||||||
|
).run()
|
||||||
|
|
||||||
for hook_type in ("on_gle_rename", "on_sle_rename"):
|
for hook_type in ("on_gle_rename", "on_sle_rename"):
|
||||||
for hook in frappe.get_hooks(hook_type):
|
for hook in frappe.get_hooks(hook_type):
|
||||||
|
|||||||
@@ -26,12 +26,17 @@ class TestGLEntry(ERPNextTestSuite):
|
|||||||
jv.flags.ignore_validate = True
|
jv.flags.ignore_validate = True
|
||||||
jv.submit()
|
jv.submit()
|
||||||
|
|
||||||
round_off_entry = frappe.db.sql(
|
round_off_entry = frappe.get_all(
|
||||||
"""select name from `tabGL Entry`
|
"GL Entry",
|
||||||
where voucher_type='Journal Entry' and voucher_no = %s
|
filters={
|
||||||
and account='_Test Write Off - _TC' and cost_center='_Test Cost Center - _TC'
|
"voucher_type": "Journal Entry",
|
||||||
and debit = 0 and credit = '.01'""",
|
"voucher_no": jv.name,
|
||||||
jv.name,
|
"account": "_Test Write Off - _TC",
|
||||||
|
"cost_center": "_Test Cost Center - _TC",
|
||||||
|
"debit": 0,
|
||||||
|
"credit": 0.01,
|
||||||
|
},
|
||||||
|
pluck="name",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(round_off_entry)
|
self.assertTrue(round_off_entry)
|
||||||
@@ -55,8 +60,9 @@ class TestGLEntry(ERPNextTestSuite):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
|
self.assertTrue(all(entry.to_rename == 1 for entry in gl_entries))
|
||||||
old_naming_series_current_value = frappe.db.sql(
|
series = frappe.qb.DocType("Series")
|
||||||
"SELECT current from tabSeries where name = %s", naming_series
|
old_naming_series_current_value = (
|
||||||
|
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
|
||||||
)[0][0]
|
)[0][0]
|
||||||
|
|
||||||
rename_gle_sle_docs()
|
rename_gle_sle_docs()
|
||||||
@@ -73,8 +79,8 @@ class TestGLEntry(ERPNextTestSuite):
|
|||||||
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
|
all(new.name != old.name for new, old in zip(gl_entries, new_gl_entries, strict=False))
|
||||||
)
|
)
|
||||||
|
|
||||||
new_naming_series_current_value = frappe.db.sql(
|
new_naming_series_current_value = (
|
||||||
"SELECT current from tabSeries where name = %s", naming_series
|
frappe.qb.from_(series).select(series["current"]).where(series.name == naming_series).run()
|
||||||
)[0][0]
|
)[0][0]
|
||||||
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)
|
self.assertEqual(old_naming_series_current_value + 2, new_naming_series_current_value)
|
||||||
|
|
||||||
|
|||||||
@@ -319,56 +319,48 @@ class InvoiceDiscounting(AccountsController):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_invoices(filters: str):
|
def get_invoices(filters: str):
|
||||||
filters = frappe._dict(json.loads(filters))
|
filters = frappe._dict(json.loads(filters))
|
||||||
cond = []
|
si = frappe.qb.DocType("Sales Invoice")
|
||||||
if filters.customer:
|
di = frappe.qb.DocType("Discounted Invoice")
|
||||||
cond.append("customer=%(customer)s")
|
|
||||||
if filters.from_date:
|
|
||||||
cond.append("posting_date >= %(from_date)s")
|
|
||||||
if filters.to_date:
|
|
||||||
cond.append("posting_date <= %(to_date)s")
|
|
||||||
if filters.min_amount:
|
|
||||||
cond.append("base_grand_total >= %(min_amount)s")
|
|
||||||
if filters.max_amount:
|
|
||||||
cond.append("base_grand_total <= %(max_amount)s")
|
|
||||||
|
|
||||||
where_condition = ""
|
discounted = frappe.qb.from_(di).select(di.sales_invoice).where(di.docstatus == 1)
|
||||||
if cond:
|
|
||||||
where_condition += " and " + " and ".join(cond)
|
|
||||||
|
|
||||||
return frappe.db.sql(
|
query = (
|
||||||
"""
|
frappe.qb.from_(si)
|
||||||
select
|
.select(
|
||||||
name as sales_invoice,
|
si.name.as_("sales_invoice"),
|
||||||
customer,
|
si.customer,
|
||||||
posting_date,
|
si.posting_date,
|
||||||
outstanding_amount,
|
si.outstanding_amount,
|
||||||
debit_to
|
si.debit_to,
|
||||||
from `tabSales Invoice` si
|
)
|
||||||
where
|
.where((si.docstatus == 1) & (si.outstanding_amount > 0) & si.name.notin(discounted))
|
||||||
docstatus = 1
|
|
||||||
and outstanding_amount > 0
|
|
||||||
%s
|
|
||||||
and not exists(select di.name from `tabDiscounted Invoice` di
|
|
||||||
where di.docstatus=1 and di.sales_invoice=si.name)
|
|
||||||
"""
|
|
||||||
% where_condition,
|
|
||||||
filters,
|
|
||||||
as_dict=1,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if filters.customer:
|
||||||
|
query = query.where(si.customer == filters.customer)
|
||||||
|
if filters.from_date:
|
||||||
|
query = query.where(si.posting_date >= filters.from_date)
|
||||||
|
if filters.to_date:
|
||||||
|
query = query.where(si.posting_date <= filters.to_date)
|
||||||
|
if filters.min_amount:
|
||||||
|
query = query.where(si.base_grand_total >= filters.min_amount)
|
||||||
|
if filters.max_amount:
|
||||||
|
query = query.where(si.base_grand_total <= filters.max_amount)
|
||||||
|
|
||||||
|
return query.run(as_dict=1)
|
||||||
|
|
||||||
|
|
||||||
def get_party_account_based_on_invoice_discounting(sales_invoice):
|
def get_party_account_based_on_invoice_discounting(sales_invoice):
|
||||||
party_account = None
|
party_account = None
|
||||||
invoice_discounting = frappe.db.sql(
|
par = frappe.qb.DocType("Invoice Discounting")
|
||||||
"""
|
ch = frappe.qb.DocType("Discounted Invoice")
|
||||||
select par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status
|
invoice_discounting = (
|
||||||
from `tabInvoice Discounting` par, `tabDiscounted Invoice` ch
|
frappe.qb.from_(par)
|
||||||
where par.name=ch.parent
|
.inner_join(ch)
|
||||||
and par.docstatus=1
|
.on(par.name == ch.parent)
|
||||||
and ch.sales_invoice = %s
|
.select(par.accounts_receivable_discounted, par.accounts_receivable_unpaid, par.status)
|
||||||
""",
|
.where((par.docstatus == 1) & (ch.sales_invoice == sales_invoice))
|
||||||
(sales_invoice),
|
.run(as_dict=1)
|
||||||
as_dict=1,
|
|
||||||
)
|
)
|
||||||
if invoice_discounting:
|
if invoice_discounting:
|
||||||
if invoice_discounting[0].status == "Disbursed":
|
if invoice_discounting[0].status == "Disbursed":
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import frappe
|
|||||||
from frappe.utils import add_days, flt, nowdate
|
from frappe.utils import add_days, flt, nowdate
|
||||||
|
|
||||||
from erpnext.accounts.doctype.account.test_account import create_account
|
from erpnext.accounts.doctype.account.test_account import create_account
|
||||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry_against_invoice
|
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_invoice
|
||||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ frappe.ui.form.on("Journal Entry", {
|
|||||||
voucher_type: frm.doc.voucher_type,
|
voucher_type: frm.doc.voucher_type,
|
||||||
company: args.company,
|
company: args.company,
|
||||||
},
|
},
|
||||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_inter_company_journal_entry",
|
method: "erpnext.accounts.doctype.journal_entry.mapper.make_inter_company_journal_entry",
|
||||||
callback: function (r) {
|
callback: function (r) {
|
||||||
if (r.message) {
|
if (r.message) {
|
||||||
var doc = frappe.model.sync(r.message)[0];
|
var doc = frappe.model.sync(r.message)[0];
|
||||||
@@ -409,18 +409,16 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
|||||||
}
|
}
|
||||||
|
|
||||||
get_outstanding(doctype, docname, company, child) {
|
get_outstanding(doctype, docname, company, child) {
|
||||||
var args = {
|
|
||||||
doctype: doctype,
|
|
||||||
docname: docname,
|
|
||||||
party: child.party,
|
|
||||||
account: child.account,
|
|
||||||
account_currency: child.account_currency,
|
|
||||||
company: company,
|
|
||||||
};
|
|
||||||
|
|
||||||
return frappe.call({
|
return frappe.call({
|
||||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
|
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
|
||||||
args: { args: args },
|
args: {
|
||||||
|
doctype: doctype,
|
||||||
|
docname: docname,
|
||||||
|
company: company,
|
||||||
|
account: child.account,
|
||||||
|
party: child.party,
|
||||||
|
account_currency: child.account_currency,
|
||||||
|
},
|
||||||
callback: function (r) {
|
callback: function (r) {
|
||||||
if (r.message) {
|
if (r.message) {
|
||||||
$.each(r.message, function (field, value) {
|
$.each(r.message, function (field, value) {
|
||||||
@@ -731,7 +729,7 @@ $.extend(erpnext.journal_entry, {
|
|||||||
|
|
||||||
reverse_journal_entry: function (frm) {
|
reverse_journal_entry: function (frm) {
|
||||||
frappe.model.open_mapped_doc({
|
frappe.model.open_mapped_doc({
|
||||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_reverse_journal_entry",
|
method: "erpnext.accounts.doctype.journal_entry.mapper.make_reverse_journal_entry",
|
||||||
frm: frm,
|
frm: frm,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
261
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
261
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
"""Document builders that map a source document to a Journal Entry or to a
|
||||||
|
Payment Entry raised against it."""
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import flt, get_link_to_form, nowdate
|
||||||
|
|
||||||
|
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||||
|
get_party_account_based_on_invoice_discounting,
|
||||||
|
)
|
||||||
|
from erpnext.accounts.party import get_party_account
|
||||||
|
from erpnext.accounts.utils import get_account_currency
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_payment_entry_against_order(
|
||||||
|
dt: str,
|
||||||
|
dn: str,
|
||||||
|
amount: float | None = None,
|
||||||
|
debit_in_account_currency: str | float | None = None,
|
||||||
|
journal_entry: bool = False,
|
||||||
|
bank_account: str | None = None,
|
||||||
|
) -> dict | Document:
|
||||||
|
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
|
||||||
|
ref_doc = frappe.get_doc(dt, dn)
|
||||||
|
|
||||||
|
if flt(ref_doc.per_billed, 2) > 0:
|
||||||
|
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||||
|
|
||||||
|
if dt == "Sales Order":
|
||||||
|
party_type = "Customer"
|
||||||
|
amount_field_party = "credit_in_account_currency"
|
||||||
|
amount_field_bank = "debit_in_account_currency"
|
||||||
|
else:
|
||||||
|
party_type = "Supplier"
|
||||||
|
amount_field_party = "debit_in_account_currency"
|
||||||
|
amount_field_bank = "credit_in_account_currency"
|
||||||
|
|
||||||
|
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||||
|
party_account_currency = get_account_currency(party_account)
|
||||||
|
|
||||||
|
if not amount:
|
||||||
|
if party_account_currency == ref_doc.company_currency:
|
||||||
|
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
|
||||||
|
else:
|
||||||
|
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
|
||||||
|
|
||||||
|
return get_payment_entry(
|
||||||
|
ref_doc,
|
||||||
|
{
|
||||||
|
"party_type": party_type,
|
||||||
|
"party_account": party_account,
|
||||||
|
"party_account_currency": party_account_currency,
|
||||||
|
"amount_field_party": amount_field_party,
|
||||||
|
"amount_field_bank": amount_field_bank,
|
||||||
|
"amount": amount,
|
||||||
|
"debit_in_account_currency": debit_in_account_currency,
|
||||||
|
"remarks": f"Advance Payment received against {dt} {dn}",
|
||||||
|
"is_advance": "Yes",
|
||||||
|
"bank_account": bank_account,
|
||||||
|
"journal_entry": journal_entry,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_payment_entry_against_invoice(
|
||||||
|
dt: str,
|
||||||
|
dn: str,
|
||||||
|
amount: float | None = None,
|
||||||
|
debit_in_account_currency: str | None = None,
|
||||||
|
journal_entry: bool = False,
|
||||||
|
bank_account: str | None = None,
|
||||||
|
) -> dict | Document:
|
||||||
|
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
|
||||||
|
ref_doc = frappe.get_doc(dt, dn)
|
||||||
|
if dt == "Sales Invoice":
|
||||||
|
party_type = "Customer"
|
||||||
|
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||||
|
else:
|
||||||
|
party_type = "Supplier"
|
||||||
|
party_account = ref_doc.credit_to
|
||||||
|
|
||||||
|
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
|
||||||
|
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
|
||||||
|
):
|
||||||
|
amount_field_party = "credit_in_account_currency"
|
||||||
|
amount_field_bank = "debit_in_account_currency"
|
||||||
|
else:
|
||||||
|
amount_field_party = "debit_in_account_currency"
|
||||||
|
amount_field_bank = "credit_in_account_currency"
|
||||||
|
|
||||||
|
return get_payment_entry(
|
||||||
|
ref_doc,
|
||||||
|
{
|
||||||
|
"party_type": party_type,
|
||||||
|
"party_account": party_account,
|
||||||
|
"party_account_currency": ref_doc.party_account_currency,
|
||||||
|
"amount_field_party": amount_field_party,
|
||||||
|
"amount_field_bank": amount_field_bank,
|
||||||
|
"amount": amount if amount else abs(ref_doc.outstanding_amount),
|
||||||
|
"debit_in_account_currency": debit_in_account_currency,
|
||||||
|
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
|
||||||
|
"is_advance": "No",
|
||||||
|
"bank_account": bank_account,
|
||||||
|
"journal_entry": journal_entry,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
|
||||||
|
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
|
||||||
|
|
||||||
|
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
|
||||||
|
dict (for client calls).
|
||||||
|
"""
|
||||||
|
je = frappe.new_doc("Journal Entry")
|
||||||
|
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||||
|
|
||||||
|
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||||
|
"Company", ref_doc.company, "cost_center"
|
||||||
|
)
|
||||||
|
exchange_rate = _reference_exchange_rate(ref_doc, args)
|
||||||
|
|
||||||
|
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||||
|
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||||
|
|
||||||
|
if party_row.account_currency != ref_doc.company_currency or (
|
||||||
|
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||||
|
):
|
||||||
|
je.multi_currency = 1
|
||||||
|
|
||||||
|
je.set_amounts_in_company_currency()
|
||||||
|
je.set_total_debit_credit()
|
||||||
|
|
||||||
|
return je if args.get("journal_entry") else je.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_exchange_rate(ref_doc, args: dict) -> float:
|
||||||
|
"""Exchange rate of the party account on the reference document's posting date."""
|
||||||
|
if not args.get("party_account"):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
|
||||||
|
|
||||||
|
return get_exchange_rate(
|
||||||
|
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||||
|
args.get("party_account"),
|
||||||
|
args.get("party_account_currency"),
|
||||||
|
ref_doc.company,
|
||||||
|
ref_doc.doctype,
|
||||||
|
ref_doc.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||||
|
"""Append the party (debtor/creditor) row that records the advance/payment."""
|
||||||
|
return je.append(
|
||||||
|
"accounts",
|
||||||
|
{
|
||||||
|
"account": args.get("party_account"),
|
||||||
|
"party_type": args.get("party_type"),
|
||||||
|
"party": ref_doc.get(args.get("party_type").lower()),
|
||||||
|
"cost_center": cost_center,
|
||||||
|
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
|
||||||
|
"account_currency": args.get("party_account_currency")
|
||||||
|
or get_account_currency(args.get("party_account")),
|
||||||
|
"exchange_rate": exchange_rate,
|
||||||
|
args.get("amount_field_party"): args.get("amount"),
|
||||||
|
"is_advance": args.get("is_advance"),
|
||||||
|
"reference_type": ref_doc.doctype,
|
||||||
|
"reference_name": ref_doc.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||||
|
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
|
||||||
|
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||||
|
get_default_bank_cash_account,
|
||||||
|
get_exchange_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
bank_row = je.append("accounts")
|
||||||
|
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||||
|
if bank_account:
|
||||||
|
bank_row.update(bank_account)
|
||||||
|
# posting date assumed to be the reference document's posting/transaction date
|
||||||
|
bank_row.exchange_rate = get_exchange_rate(
|
||||||
|
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||||
|
bank_account["account"],
|
||||||
|
bank_account["account_currency"],
|
||||||
|
ref_doc.company,
|
||||||
|
)
|
||||||
|
|
||||||
|
bank_row.cost_center = cost_center
|
||||||
|
|
||||||
|
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||||
|
if bank_row.account_currency == args.get("party_account_currency"):
|
||||||
|
bank_row.set(args.get("amount_field_bank"), amount)
|
||||||
|
else:
|
||||||
|
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||||
|
|
||||||
|
return bank_row
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
|
||||||
|
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
|
||||||
|
journal_entry = frappe.new_doc("Journal Entry")
|
||||||
|
journal_entry.voucher_type = voucher_type
|
||||||
|
journal_entry.company = company
|
||||||
|
journal_entry.posting_date = nowdate()
|
||||||
|
journal_entry.inter_company_journal_entry_reference = name
|
||||||
|
return journal_entry.as_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
|
||||||
|
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
|
||||||
|
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||||
|
if existing_reverse:
|
||||||
|
frappe.throw(
|
||||||
|
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
|
||||||
|
get_link_to_form("Journal Entry", existing_reverse)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
from frappe.model.mapper import get_mapped_doc
|
||||||
|
|
||||||
|
def post_process(source, target) -> None:
|
||||||
|
target.reversal_of = source.name
|
||||||
|
|
||||||
|
doclist = get_mapped_doc(
|
||||||
|
"Journal Entry",
|
||||||
|
source_name,
|
||||||
|
{
|
||||||
|
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
|
||||||
|
"Journal Entry Account": {
|
||||||
|
"doctype": "Journal Entry Account",
|
||||||
|
"field_map": {
|
||||||
|
"account_currency": "account_currency",
|
||||||
|
"exchange_rate": "exchange_rate",
|
||||||
|
"debit_in_account_currency": "credit_in_account_currency",
|
||||||
|
"debit": "credit",
|
||||||
|
"credit_in_account_currency": "debit_in_account_currency",
|
||||||
|
"credit": "debit",
|
||||||
|
"reference_type": "reference_type",
|
||||||
|
"reference_name": "reference_name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
target_doc,
|
||||||
|
post_process,
|
||||||
|
)
|
||||||
|
|
||||||
|
return doclist
|
||||||
200
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
200
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.utils import flt
|
||||||
|
|
||||||
|
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||||
|
get_depr_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssetService:
|
||||||
|
"""Keeps Assets in sync with the Journal Entries that depreciate, dispose or
|
||||||
|
adjust them.
|
||||||
|
|
||||||
|
On submit of a Depreciation Entry it reduces the asset value and links the
|
||||||
|
depreciation schedule; on submit of an Asset Disposal it marks the asset
|
||||||
|
disposed. On cancel it reverses those links. It also guards cancellation of
|
||||||
|
Journal Entries tied to asset scrapping or value adjustments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, doc) -> None:
|
||||||
|
self.doc = doc
|
||||||
|
|
||||||
|
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
|
||||||
|
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
|
||||||
|
for d in self.doc.get("accounts"):
|
||||||
|
if d.account_type == "Depreciation":
|
||||||
|
if self.doc.voucher_type != "Depreciation Entry":
|
||||||
|
frappe.throw(
|
||||||
|
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
|
||||||
|
)
|
||||||
|
|
||||||
|
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||||
|
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||||
|
|
||||||
|
def has_asset_adjustment_entry(self) -> None:
|
||||||
|
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
|
||||||
|
if self.doc.flags.get("via_asset_value_adjustment"):
|
||||||
|
return
|
||||||
|
|
||||||
|
asset_value_adjustment = frappe.db.get_value(
|
||||||
|
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.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 update_asset_value(self) -> None:
|
||||||
|
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
|
||||||
|
self.update_asset_on_depreciation()
|
||||||
|
self.update_asset_on_disposal()
|
||||||
|
|
||||||
|
def update_asset_on_depreciation(self) -> None:
|
||||||
|
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
|
||||||
|
if self.doc.voucher_type != "Depreciation Entry":
|
||||||
|
return
|
||||||
|
|
||||||
|
for d in self.doc.get("accounts"):
|
||||||
|
if (
|
||||||
|
d.reference_type == "Asset"
|
||||||
|
and d.reference_name
|
||||||
|
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||||
|
and d.debit
|
||||||
|
):
|
||||||
|
asset = frappe.get_cached_doc("Asset", d.reference_name)
|
||||||
|
|
||||||
|
if asset.calculate_depreciation:
|
||||||
|
self.update_journal_entry_link_on_depr_schedule(asset, d)
|
||||||
|
self.update_value_after_depreciation(asset, d.debit)
|
||||||
|
|
||||||
|
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
|
||||||
|
asset.set_status()
|
||||||
|
asset.set_total_booked_depreciations()
|
||||||
|
|
||||||
|
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
|
||||||
|
"""Subtract the depreciation amount from the asset's relevant finance book."""
|
||||||
|
fb_idx = 1
|
||||||
|
if self.doc.finance_book:
|
||||||
|
for fb_row in asset.get("finance_books"):
|
||||||
|
if fb_row.finance_book == self.doc.finance_book:
|
||||||
|
fb_idx = fb_row.idx
|
||||||
|
break
|
||||||
|
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||||
|
fb_row.value_after_depreciation -= depr_amount
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
|
||||||
|
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
|
||||||
|
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
|
||||||
|
for d in depr_schedule or []:
|
||||||
|
if (
|
||||||
|
d.schedule_date == self.doc.posting_date
|
||||||
|
and not d.journal_entry
|
||||||
|
and d.depreciation_amount == flt(je_row.debit)
|
||||||
|
):
|
||||||
|
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||||
|
|
||||||
|
def update_asset_on_disposal(self) -> None:
|
||||||
|
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
|
||||||
|
if self.doc.voucher_type == "Asset Disposal":
|
||||||
|
disposed_assets = []
|
||||||
|
for d in self.doc.get("accounts"):
|
||||||
|
if (
|
||||||
|
d.reference_type == "Asset"
|
||||||
|
and d.reference_name
|
||||||
|
and d.reference_name not in disposed_assets
|
||||||
|
):
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Asset",
|
||||||
|
d.reference_name,
|
||||||
|
{
|
||||||
|
"disposal_date": self.doc.posting_date,
|
||||||
|
"journal_entry_for_scrap": self.doc.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
asset_doc = frappe.get_doc("Asset", d.reference_name)
|
||||||
|
asset_doc.set_status()
|
||||||
|
disposed_assets.append(d.reference_name)
|
||||||
|
|
||||||
|
def unlink_asset_reference(self) -> None:
|
||||||
|
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
|
||||||
|
for d in self.doc.get("accounts"):
|
||||||
|
if self._is_depreciation_asset_row(d):
|
||||||
|
self._reverse_asset_depreciation(d)
|
||||||
|
elif (
|
||||||
|
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
|
||||||
|
):
|
||||||
|
self._block_scrap_journal_cancel(d)
|
||||||
|
|
||||||
|
def _is_depreciation_asset_row(self, d) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.doc.voucher_type == "Depreciation Entry"
|
||||||
|
and d.reference_type == "Asset"
|
||||||
|
and d.reference_name
|
||||||
|
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||||
|
and d.debit
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reverse_asset_depreciation(self, d) -> None:
|
||||||
|
"""Add the depreciation amount back to the asset and unlink its schedule row."""
|
||||||
|
asset = frappe.get_doc("Asset", d.reference_name)
|
||||||
|
|
||||||
|
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
|
||||||
|
self._restore_finance_book_value(asset, d.debit)
|
||||||
|
|
||||||
|
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||||
|
asset.set_status()
|
||||||
|
asset.set_total_booked_depreciations()
|
||||||
|
|
||||||
|
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
|
||||||
|
"""Unlink this entry from the depreciation schedule and credit back its finance book.
|
||||||
|
|
||||||
|
Returns True if a matching scheduled depreciation was found.
|
||||||
|
"""
|
||||||
|
for fb_row in asset.get("finance_books"):
|
||||||
|
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||||
|
for s in depr_schedule or []:
|
||||||
|
if s.journal_entry == self.doc.name:
|
||||||
|
s.db_set("journal_entry", None)
|
||||||
|
fb_row.value_after_depreciation += debit
|
||||||
|
fb_row.db_update()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _restore_finance_book_value(self, asset, debit: float) -> None:
|
||||||
|
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
|
||||||
|
fb_idx = 1
|
||||||
|
if self.doc.finance_book:
|
||||||
|
for fb_row in asset.get("finance_books"):
|
||||||
|
if fb_row.finance_book == self.doc.finance_book:
|
||||||
|
fb_idx = fb_row.idx
|
||||||
|
break
|
||||||
|
|
||||||
|
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||||
|
fb_row.value_after_depreciation += debit
|
||||||
|
fb_row.db_update()
|
||||||
|
|
||||||
|
def _block_scrap_journal_cancel(self, d) -> None:
|
||||||
|
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
|
||||||
|
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
|
||||||
|
if journal_entry_for_scrap == self.doc.name:
|
||||||
|
frappe.throw(
|
||||||
|
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||||
|
)
|
||||||
|
|
||||||
|
def unlink_asset_adjustment_entry(self) -> None:
|
||||||
|
"""Detach this entry from any Asset Value Adjustment that referenced it."""
|
||||||
|
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||||
|
(
|
||||||
|
frappe.qb.update(AssetValueAdjustment)
|
||||||
|
.set(AssetValueAdjustment.journal_entry, None)
|
||||||
|
.where(AssetValueAdjustment.journal_entry == self.doc.name)
|
||||||
|
).run()
|
||||||
105
erpnext/accounts/doctype/journal_entry/services/gl_composer.py
Normal file
105
erpnext/accounts/doctype/journal_entry/services/gl_composer.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.utils import flt
|
||||||
|
|
||||||
|
import erpnext
|
||||||
|
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
|
||||||
|
from erpnext.accounts.utils import get_advance_payment_doctypes
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryGLComposer(BaseGLComposer):
|
||||||
|
"""Assembles the GL entries for a Journal Entry.
|
||||||
|
|
||||||
|
A Journal Entry already carries its ledger rows in the ``accounts`` child
|
||||||
|
table, so composing is a straight projection of those rows into GL dicts
|
||||||
|
via ``self.get_gl_dict``. The transaction currency/rate are resolved
|
||||||
|
from the first foreign-currency row (mirroring the former build_gl_map).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> list:
|
||||||
|
"""Project the Journal Entry's non-zero account rows into GL dicts."""
|
||||||
|
self._set_transaction_currency()
|
||||||
|
advance_doctypes = get_advance_payment_doctypes()
|
||||||
|
|
||||||
|
gl_map = []
|
||||||
|
for d in self.doc.get("accounts"):
|
||||||
|
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||||
|
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
|
||||||
|
return gl_map
|
||||||
|
|
||||||
|
def _set_transaction_currency(self) -> None:
|
||||||
|
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
|
||||||
|
doc = self.doc
|
||||||
|
doc.transaction_currency = erpnext.get_company_currency(doc.company)
|
||||||
|
doc.transaction_exchange_rate = 1
|
||||||
|
if not doc.multi_currency:
|
||||||
|
return
|
||||||
|
|
||||||
|
for row in doc.get("accounts"):
|
||||||
|
if row.account_currency != doc.transaction_currency:
|
||||||
|
# Journal assumes the first foreign currency as transaction currency
|
||||||
|
doc.transaction_currency = row.account_currency
|
||||||
|
doc.transaction_exchange_rate = row.exchange_rate
|
||||||
|
break
|
||||||
|
|
||||||
|
def _gl_row(self, d, advance_doctypes: list) -> dict:
|
||||||
|
"""Build the GL dict for a single account row."""
|
||||||
|
doc = self.doc
|
||||||
|
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"account": d.account,
|
||||||
|
"party_type": d.party_type,
|
||||||
|
"due_date": doc.due_date,
|
||||||
|
"party": d.party,
|
||||||
|
"against": d.against_account,
|
||||||
|
"debit": flt(d.debit, d.precision("debit")),
|
||||||
|
"credit": flt(d.credit, d.precision("credit")),
|
||||||
|
"account_currency": d.account_currency,
|
||||||
|
"debit_in_account_currency": flt(
|
||||||
|
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||||
|
),
|
||||||
|
"credit_in_account_currency": flt(
|
||||||
|
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||||
|
),
|
||||||
|
"transaction_currency": doc.transaction_currency,
|
||||||
|
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||||
|
"debit_in_transaction_currency": flt(
|
||||||
|
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||||
|
)
|
||||||
|
if doc.transaction_currency == d.account_currency
|
||||||
|
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||||
|
"credit_in_transaction_currency": flt(
|
||||||
|
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||||
|
)
|
||||||
|
if doc.transaction_currency == d.account_currency
|
||||||
|
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||||
|
"against_voucher_type": d.reference_type,
|
||||||
|
"against_voucher": d.reference_name,
|
||||||
|
"remarks": remarks,
|
||||||
|
"voucher_detail_no": d.reference_detail_no,
|
||||||
|
"cost_center": d.cost_center,
|
||||||
|
"project": d.project,
|
||||||
|
"finance_book": doc.finance_book,
|
||||||
|
"advance_voucher_type": d.advance_voucher_type,
|
||||||
|
"advance_voucher_no": d.advance_voucher_no,
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.reference_type in advance_doctypes:
|
||||||
|
row.update(
|
||||||
|
{
|
||||||
|
"against_voucher_type": doc.doctype,
|
||||||
|
"against_voucher": doc.name,
|
||||||
|
"advance_voucher_type": d.reference_type,
|
||||||
|
"advance_voucher_no": d.reference_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# set flag to skip party validation
|
||||||
|
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||||
|
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||||
|
frappe.flags.party_not_required = True
|
||||||
|
|
||||||
|
return row
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _, scrub
|
||||||
|
from frappe.utils import cstr, flt, fmt_money
|
||||||
|
|
||||||
|
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
|
||||||
|
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||||
|
get_party_account_based_on_invoice_discounting,
|
||||||
|
)
|
||||||
|
from erpnext.accounts.utils import get_account_currency
|
||||||
|
|
||||||
|
REFERENCE_PARTY_ACCOUNT_FIELDS = {
|
||||||
|
"Sales Invoice": ["Customer", "Debit To"],
|
||||||
|
"Purchase Invoice": ["Supplier", "Credit To"],
|
||||||
|
"Sales Order": ["Customer"],
|
||||||
|
"Purchase Order": ["Supplier"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryReferenceValidator:
|
||||||
|
"""Validates Journal Entry account rows against their referenced documents.
|
||||||
|
|
||||||
|
For each row that links a Sales/Purchase Invoice or Order, this checks the
|
||||||
|
debit/credit direction, party and account match, and aggregates per-reference
|
||||||
|
totals (held on the document as ``reference_totals``/``reference_types``/
|
||||||
|
``reference_accounts``) which are then validated against the referenced
|
||||||
|
orders and invoices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, doc) -> None:
|
||||||
|
self.doc = doc
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Validate every reference-bearing row, then the referenced orders and invoices."""
|
||||||
|
self.doc.reference_totals = {}
|
||||||
|
self.doc.reference_types = {}
|
||||||
|
self.doc.reference_accounts = {}
|
||||||
|
for row in self.doc.get("accounts"):
|
||||||
|
self._normalize_reference_fields(row)
|
||||||
|
if not self._has_party_reference(row):
|
||||||
|
continue
|
||||||
|
self._validate_order_direction(row)
|
||||||
|
self._register_reference(row)
|
||||||
|
self._validate_reference_party_and_account(row)
|
||||||
|
|
||||||
|
self._validate_orders()
|
||||||
|
self._validate_invoices()
|
||||||
|
|
||||||
|
def _normalize_reference_fields(self, row) -> None:
|
||||||
|
if not row.reference_type:
|
||||||
|
row.reference_name = None
|
||||||
|
if not row.reference_name:
|
||||||
|
row.reference_type = None
|
||||||
|
|
||||||
|
def _has_party_reference(self, row) -> bool:
|
||||||
|
return bool(
|
||||||
|
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reference_amount_field(self, row) -> str:
|
||||||
|
if row.reference_type in ("Sales Order", "Sales Invoice"):
|
||||||
|
return "credit_in_account_currency"
|
||||||
|
return "debit_in_account_currency"
|
||||||
|
|
||||||
|
def _validate_order_direction(self, row) -> None:
|
||||||
|
"""An order can only be linked on the side that records an advance."""
|
||||||
|
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
|
||||||
|
frappe.throw(
|
||||||
|
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||||
|
)
|
||||||
|
if row.reference_type == "Purchase Order" and flt(row.credit) > 0:
|
||||||
|
frappe.throw(
|
||||||
|
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _register_reference(self, row) -> None:
|
||||||
|
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
|
||||||
|
if row.reference_name not in self.doc.reference_totals:
|
||||||
|
self.doc.reference_totals[row.reference_name] = 0.0
|
||||||
|
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||||
|
self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row)))
|
||||||
|
self.doc.reference_types[row.reference_name] = row.reference_type
|
||||||
|
self.doc.reference_accounts[row.reference_name] = row.account
|
||||||
|
|
||||||
|
def _validate_reference_party_and_account(self, row) -> None:
|
||||||
|
"""Reject a missing reference, then check party/account against the linked document."""
|
||||||
|
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
|
||||||
|
against_voucher = frappe.db.get_value(
|
||||||
|
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
|
||||||
|
)
|
||||||
|
if not against_voucher:
|
||||||
|
frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name))
|
||||||
|
|
||||||
|
if row.reference_type in ("Sales Invoice", "Purchase Invoice"):
|
||||||
|
self._validate_invoice_party_and_account(row, against_voucher, party_fields)
|
||||||
|
elif row.reference_type in ("Sales Order", "Purchase Order"):
|
||||||
|
self._validate_order_party(row, against_voucher)
|
||||||
|
|
||||||
|
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
|
||||||
|
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
|
||||||
|
if self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||||
|
return
|
||||||
|
if against_party != cstr(row.party) or party_account != row.account:
|
||||||
|
frappe.throw(
|
||||||
|
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
|
||||||
|
row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
|
||||||
|
"""Expected (party_account, party) for an invoice row, honouring deferred booking
|
||||||
|
and invoice-discounting accounts."""
|
||||||
|
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
|
||||||
|
debit_or_credit = "Debit" if row.debit else "Credit"
|
||||||
|
party_account = get_deferred_booking_accounts(
|
||||||
|
row.reference_type, row.reference_detail_no, debit_or_credit
|
||||||
|
)
|
||||||
|
return party_account, ""
|
||||||
|
if row.reference_type == "Sales Invoice":
|
||||||
|
party_account = (
|
||||||
|
get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
party_account = against_voucher[1]
|
||||||
|
return party_account, against_voucher[0]
|
||||||
|
|
||||||
|
def _validate_order_party(self, row, against_voucher) -> None:
|
||||||
|
if against_voucher != row.party:
|
||||||
|
frappe.throw(
|
||||||
|
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||||
|
row.idx, row.party_type, row.party, row.reference_type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_orders(self) -> None:
|
||||||
|
"""Validate totals, closed and docstatus for referenced orders."""
|
||||||
|
for reference_name, total in self.doc.reference_totals.items():
|
||||||
|
reference_type = self.doc.reference_types[reference_name]
|
||||||
|
account = self.doc.reference_accounts[reference_name]
|
||||||
|
if reference_type not in ("Sales Order", "Purchase Order"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
order = frappe.get_doc(reference_type, reference_name)
|
||||||
|
self._validate_order_status(order, reference_type, reference_name)
|
||||||
|
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
|
||||||
|
|
||||||
|
def _validate_order_status(self, order, reference_type, reference_name) -> None:
|
||||||
|
if order.docstatus != 1:
|
||||||
|
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||||
|
if flt(order.per_billed) >= 100:
|
||||||
|
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
|
||||||
|
if cstr(order.status) == "Closed":
|
||||||
|
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||||
|
|
||||||
|
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
|
||||||
|
"""The advance paid against an order cannot exceed its grand total."""
|
||||||
|
account_currency = get_account_currency(account)
|
||||||
|
if account_currency == self.doc.company_currency:
|
||||||
|
voucher_total = order.base_grand_total
|
||||||
|
field = "base_grand_total"
|
||||||
|
else:
|
||||||
|
voucher_total = order.grand_total
|
||||||
|
field = "grand_total"
|
||||||
|
|
||||||
|
if flt(voucher_total) < (flt(order.advance_paid) + total):
|
||||||
|
formatted_voucher_total = fmt_money(
|
||||||
|
voucher_total, order.precision(field), currency=account_currency
|
||||||
|
)
|
||||||
|
frappe.throw(
|
||||||
|
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
|
||||||
|
reference_type, reference_name, formatted_voucher_total
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_invoices(self) -> None:
|
||||||
|
"""Validate totals and docstatus for referenced invoices."""
|
||||||
|
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
|
||||||
|
return
|
||||||
|
for reference_name, total in self.doc.reference_totals.items():
|
||||||
|
reference_type = self.doc.reference_types[reference_name]
|
||||||
|
if reference_type not in ("Sales Invoice", "Purchase Invoice"):
|
||||||
|
continue
|
||||||
|
invoice = frappe.get_doc(reference_type, reference_name)
|
||||||
|
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
|
||||||
|
|
||||||
|
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
|
||||||
|
"""Payment booked against an invoice cannot exceed its outstanding amount."""
|
||||||
|
if invoice.docstatus != 1:
|
||||||
|
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||||
|
|
||||||
|
precision = invoice.precision("outstanding_amount")
|
||||||
|
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
|
||||||
|
frappe.throw(
|
||||||
|
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
|
||||||
|
reference_type, reference_name, invoice.outstanding_amount
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -89,7 +89,7 @@ class TestJournalEntry(ERPNextTestSuite):
|
|||||||
)
|
)
|
||||||
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
|
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
|
||||||
|
|
||||||
self.assertTrue(flt(advance_paid[0][0]) == flt(payment_against_order))
|
self.assertEqual(flt(advance_paid[0][0]), flt(payment_against_order))
|
||||||
|
|
||||||
def cancel_against_voucher_testcase(self, test_voucher):
|
def cancel_against_voucher_testcase(self, test_voucher):
|
||||||
if test_voucher.doctype == "Journal Entry":
|
if test_voucher.doctype == "Journal Entry":
|
||||||
@@ -169,8 +169,11 @@ class TestJournalEntry(ERPNextTestSuite):
|
|||||||
"debit_in_account_currency",
|
"debit_in_account_currency",
|
||||||
"credit",
|
"credit",
|
||||||
"credit_in_account_currency",
|
"credit_in_account_currency",
|
||||||
|
"debit_in_transaction_currency",
|
||||||
|
"credit_in_transaction_currency",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
|
||||||
self.expected_gle = [
|
self.expected_gle = [
|
||||||
{
|
{
|
||||||
"account": "_Test Bank - _TC",
|
"account": "_Test Bank - _TC",
|
||||||
@@ -179,6 +182,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
|||||||
"debit_in_account_currency": 0,
|
"debit_in_account_currency": 0,
|
||||||
"credit": 5000,
|
"credit": 5000,
|
||||||
"credit_in_account_currency": 5000,
|
"credit_in_account_currency": 5000,
|
||||||
|
"debit_in_transaction_currency": 0,
|
||||||
|
"credit_in_transaction_currency": 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"account": "_Test Bank USD - _TC",
|
"account": "_Test Bank USD - _TC",
|
||||||
@@ -187,6 +192,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
|||||||
"debit_in_account_currency": 100,
|
"debit_in_account_currency": 100,
|
||||||
"credit": 0,
|
"credit": 0,
|
||||||
"credit_in_account_currency": 0,
|
"credit_in_account_currency": 0,
|
||||||
|
"debit_in_transaction_currency": 100,
|
||||||
|
"credit_in_transaction_currency": 0,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -203,8 +210,54 @@ class TestJournalEntry(ERPNextTestSuite):
|
|||||||
|
|
||||||
self.assertFalse(gle)
|
self.assertFalse(gle)
|
||||||
|
|
||||||
|
def test_multi_currency_transaction_currency_on_foreign_debit(self):
|
||||||
|
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
|
||||||
|
|
||||||
|
Transaction currency is USD (the first foreign row); the INR debit row must be
|
||||||
|
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
|
||||||
|
"""
|
||||||
|
jv = frappe.new_doc("Journal Entry")
|
||||||
|
jv.company = "_Test Company"
|
||||||
|
jv.posting_date = nowdate()
|
||||||
|
jv.multi_currency = 1
|
||||||
|
jv.append(
|
||||||
|
"accounts",
|
||||||
|
{
|
||||||
|
"account": "_Test Bank USD - _TC",
|
||||||
|
"cost_center": "_Test Cost Center - _TC",
|
||||||
|
"credit_in_account_currency": 100,
|
||||||
|
"exchange_rate": 50,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
jv.append(
|
||||||
|
"accounts",
|
||||||
|
{
|
||||||
|
"account": "_Test Bank - _TC",
|
||||||
|
"cost_center": "_Test Cost Center - _TC",
|
||||||
|
"debit_in_account_currency": 5000,
|
||||||
|
"exchange_rate": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
jv.submit()
|
||||||
|
|
||||||
|
self.voucher_no = jv.name
|
||||||
|
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
|
||||||
|
self.expected_gle = [
|
||||||
|
{
|
||||||
|
"account": "_Test Bank - _TC",
|
||||||
|
"debit_in_transaction_currency": 100,
|
||||||
|
"credit_in_transaction_currency": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account": "_Test Bank USD - _TC",
|
||||||
|
"debit_in_transaction_currency": 0,
|
||||||
|
"credit_in_transaction_currency": 100,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self.check_gl_entries()
|
||||||
|
|
||||||
def test_reverse_journal_entry(self):
|
def test_reverse_journal_entry(self):
|
||||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||||
|
|
||||||
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
|
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
|
||||||
|
|
||||||
@@ -609,6 +662,174 @@ class TestJournalEntry(ERPNextTestSuite):
|
|||||||
jv.save()
|
jv.save()
|
||||||
self.assertRaises(frappe.ValidationError, jv.submit)
|
self.assertRaises(frappe.ValidationError, jv.submit)
|
||||||
|
|
||||||
|
def test_validate_reference_doc_debit_against_sales_order_throws(self):
|
||||||
|
"""Characterize: a debit entry linked to a Sales Order is rejected."""
|
||||||
|
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||||
|
|
||||||
|
sales_order = make_sales_order()
|
||||||
|
jv = make_journal_entry("Debtors - _TC", "_Test Cash - _TC", 100, save=False)
|
||||||
|
jv.accounts[0].party_type = "Customer"
|
||||||
|
jv.accounts[0].party = "_Test Customer"
|
||||||
|
jv.accounts[0].reference_type = "Sales Order"
|
||||||
|
jv.accounts[0].reference_name = sales_order.name
|
||||||
|
self.assertRaisesRegex(frappe.ValidationError, "Debit entry can not be linked", jv.insert)
|
||||||
|
|
||||||
|
def test_validate_reference_doc_credit_against_purchase_order_throws(self):
|
||||||
|
"""Characterize: a credit entry linked to a Purchase Order is rejected."""
|
||||||
|
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||||
|
|
||||||
|
purchase_order = create_purchase_order()
|
||||||
|
jv = make_journal_entry("_Test Cash - _TC", "Creditors - _TC", 100, save=False)
|
||||||
|
jv.accounts[1].party_type = "Supplier"
|
||||||
|
jv.accounts[1].party = "_Test Supplier"
|
||||||
|
jv.accounts[1].reference_type = "Purchase Order"
|
||||||
|
jv.accounts[1].reference_name = purchase_order.name
|
||||||
|
self.assertRaisesRegex(frappe.ValidationError, "Credit entry can not be linked", jv.insert)
|
||||||
|
|
||||||
|
def test_validate_reference_doc_nonexistent_reference_rejected(self):
|
||||||
|
"""Characterize: a JE referencing a non-existent invoice is rejected by link validation.
|
||||||
|
|
||||||
|
Note: the controller's own "Invalid reference" branch is unreachable in normal flow
|
||||||
|
because Frappe link validation rejects the missing reference before validate_reference_doc.
|
||||||
|
"""
|
||||||
|
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||||
|
jv.accounts[1].party_type = "Customer"
|
||||||
|
jv.accounts[1].party = "_Test Customer"
|
||||||
|
jv.accounts[1].reference_type = "Sales Invoice"
|
||||||
|
jv.accounts[1].reference_name = "NON-EXISTENT-SI"
|
||||||
|
self.assertRaises(frappe.LinkValidationError, jv.insert)
|
||||||
|
|
||||||
|
def test_validate_reference_doc_invoice_party_mismatch_throws(self):
|
||||||
|
"""Characterize: an invoice reference whose party differs from the row party is rejected."""
|
||||||
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
|
|
||||||
|
invoice = create_sales_invoice(rate=500)
|
||||||
|
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||||
|
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||||
|
jv.accounts[1].party_type = "Customer"
|
||||||
|
jv.accounts[1].party = other_customer
|
||||||
|
jv.accounts[1].reference_type = "Sales Invoice"
|
||||||
|
jv.accounts[1].reference_name = invoice.name
|
||||||
|
self.assertRaisesRegex(frappe.ValidationError, "Party / Account does not match", jv.insert)
|
||||||
|
|
||||||
|
def test_validate_reference_doc_order_party_mismatch_throws(self):
|
||||||
|
"""Characterize: a Sales Order reference whose party differs from the row party is rejected."""
|
||||||
|
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||||
|
|
||||||
|
sales_order = make_sales_order()
|
||||||
|
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||||
|
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||||
|
jv.accounts[1].party_type = "Customer"
|
||||||
|
jv.accounts[1].party = other_customer
|
||||||
|
jv.accounts[1].is_advance = "Yes"
|
||||||
|
jv.accounts[1].reference_type = "Sales Order"
|
||||||
|
jv.accounts[1].reference_name = sales_order.name
|
||||||
|
self.assertRaisesRegex(frappe.ValidationError, "does not match", jv.insert)
|
||||||
|
|
||||||
|
def test_validate_reference_doc_populates_reference_side_effects(self):
|
||||||
|
"""Characterize: a valid invoice reference populates reference_totals/types/accounts."""
|
||||||
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
|
|
||||||
|
invoice = create_sales_invoice(rate=500)
|
||||||
|
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||||
|
jv.accounts[1].party_type = "Customer"
|
||||||
|
jv.accounts[1].party = "_Test Customer"
|
||||||
|
jv.accounts[1].reference_type = "Sales Invoice"
|
||||||
|
jv.accounts[1].reference_name = invoice.name
|
||||||
|
jv.insert()
|
||||||
|
self.assertEqual(jv.reference_totals[invoice.name], 100.0)
|
||||||
|
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||||
|
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
|
||||||
|
|
||||||
|
def test_get_balance_places_difference_on_blank_row(self):
|
||||||
|
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
|
||||||
|
jv = frappe.new_doc("Journal Entry")
|
||||||
|
jv.company = "_Test Company"
|
||||||
|
jv.posting_date = nowdate()
|
||||||
|
jv.append(
|
||||||
|
"accounts",
|
||||||
|
{
|
||||||
|
"account": "_Test Cash - _TC",
|
||||||
|
"debit_in_account_currency": 100,
|
||||||
|
"debit": 100,
|
||||||
|
"exchange_rate": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
|
||||||
|
jv.set_total_debit_credit()
|
||||||
|
self.assertEqual(jv.difference, 100)
|
||||||
|
|
||||||
|
jv.get_balance()
|
||||||
|
blank_row = jv.accounts[1]
|
||||||
|
self.assertEqual(blank_row.credit_in_account_currency, 100)
|
||||||
|
self.assertEqual(jv.total_debit, jv.total_credit)
|
||||||
|
|
||||||
|
def test_get_outstanding_invoices_builds_write_off_rows(self):
|
||||||
|
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
|
||||||
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
|
|
||||||
|
invoice = create_sales_invoice(rate=700)
|
||||||
|
jv = frappe.new_doc("Journal Entry")
|
||||||
|
jv.company = "_Test Company"
|
||||||
|
jv.posting_date = nowdate()
|
||||||
|
jv.voucher_type = "Write Off Entry"
|
||||||
|
jv.write_off_based_on = "Accounts Receivable"
|
||||||
|
jv.write_off_amount = 1000
|
||||||
|
jv.get_outstanding_invoices()
|
||||||
|
|
||||||
|
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
|
||||||
|
self.assertTrue(invoice_rows)
|
||||||
|
self.assertEqual(invoice_rows[0].party_type, "Customer")
|
||||||
|
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
|
||||||
|
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
|
||||||
|
|
||||||
|
def test_unlink_advance_entry_reference_on_cancel(self):
|
||||||
|
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
|
||||||
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
|
|
||||||
|
invoice = create_sales_invoice(rate=700)
|
||||||
|
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||||
|
advance_row = jv.accounts[1]
|
||||||
|
advance_row.party_type = "Customer"
|
||||||
|
advance_row.party = "_Test Customer"
|
||||||
|
advance_row.is_advance = "Yes"
|
||||||
|
advance_row.reference_type = "Sales Invoice"
|
||||||
|
advance_row.reference_name = invoice.name
|
||||||
|
jv.submit()
|
||||||
|
|
||||||
|
jv.cancel()
|
||||||
|
jv.reload()
|
||||||
|
self.assertFalse(jv.accounts[1].reference_type)
|
||||||
|
self.assertFalse(jv.accounts[1].reference_name)
|
||||||
|
|
||||||
|
def test_get_payment_entry_against_order_builds_advance_je(self):
|
||||||
|
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
|
||||||
|
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
|
||||||
|
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||||
|
|
||||||
|
sales_order = make_sales_order()
|
||||||
|
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
|
||||||
|
|
||||||
|
self.assertEqual(je.voucher_type, "Bank Entry")
|
||||||
|
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
|
||||||
|
self.assertTrue(party_rows)
|
||||||
|
self.assertEqual(party_rows[0].reference_type, "Sales Order")
|
||||||
|
self.assertEqual(party_rows[0].reference_name, sales_order.name)
|
||||||
|
self.assertEqual(party_rows[0].is_advance, "Yes")
|
||||||
|
|
||||||
|
def test_make_inter_company_journal_entry_builds_linked_draft(self):
|
||||||
|
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
|
||||||
|
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
|
||||||
|
|
||||||
|
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
|
||||||
|
result = make_inter_company_journal_entry(
|
||||||
|
source.name, "Inter Company Journal Entry", "_Test Company 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
|
||||||
|
self.assertEqual(result.get("company"), "_Test Company 1")
|
||||||
|
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
|
||||||
|
|
||||||
|
|
||||||
def make_journal_entry(
|
def make_journal_entry(
|
||||||
account1,
|
account1,
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ from erpnext.tests.utils import ERPNextTestSuite
|
|||||||
|
|
||||||
class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
|
class TestLedgerHealth(ERPNextTestSuite, AccountsTestMixin):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.create_company()
|
self.company = "_Test Company"
|
||||||
self.create_customer()
|
self.customer = "_Test Customer"
|
||||||
|
self.debit_to = "Debtors - _TC"
|
||||||
|
self.income_account = "Sales - _TC"
|
||||||
self.configure_monitoring_tool()
|
self.configure_monitoring_tool()
|
||||||
self.clear_old_entries()
|
|
||||||
|
|
||||||
def configure_monitoring_tool(self):
|
def configure_monitoring_tool(self):
|
||||||
monitor_settings = frappe.get_doc("Ledger Health Monitor")
|
monitor_settings = frappe.get_doc("Ledger Health Monitor")
|
||||||
|
|||||||
@@ -39,28 +39,32 @@ def get_loyalty_point_entries(customer, loyalty_program, company, expiry_date=No
|
|||||||
if not expiry_date:
|
if not expiry_date:
|
||||||
expiry_date = today()
|
expiry_date = today()
|
||||||
|
|
||||||
return frappe.db.sql(
|
return frappe.get_all(
|
||||||
"""
|
"Loyalty Point Entry",
|
||||||
select name, loyalty_points, expiry_date, loyalty_program_tier, invoice_type, invoice
|
filters={
|
||||||
from `tabLoyalty Point Entry`
|
"customer": customer,
|
||||||
where customer=%s and loyalty_program=%s
|
"loyalty_program": loyalty_program,
|
||||||
and expiry_date>=%s and loyalty_points>0 and company=%s
|
"expiry_date": [">=", expiry_date],
|
||||||
order by expiry_date
|
"loyalty_points": [">", 0],
|
||||||
""",
|
"company": company,
|
||||||
(customer, loyalty_program, expiry_date, company),
|
},
|
||||||
as_dict=1,
|
fields=["name", "loyalty_points", "expiry_date", "loyalty_program_tier", "invoice_type", "invoice"],
|
||||||
|
order_by="expiry_date",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_redemption_details(customer, loyalty_program, company):
|
def get_redemption_details(customer, loyalty_program, company):
|
||||||
return frappe._dict(
|
return frappe._dict(
|
||||||
frappe.db.sql(
|
frappe.get_all(
|
||||||
"""
|
"Loyalty Point Entry",
|
||||||
select redeem_against, sum(loyalty_points)
|
filters={
|
||||||
from `tabLoyalty Point Entry`
|
"customer": customer,
|
||||||
where customer=%s and loyalty_program=%s and loyalty_points<0 and company=%s
|
"loyalty_program": loyalty_program,
|
||||||
group by redeem_against
|
"loyalty_points": ["<", 0],
|
||||||
""",
|
"company": company,
|
||||||
(customer, loyalty_program, company),
|
},
|
||||||
|
fields=["redeem_against", {"SUM": "loyalty_points", "as": "loyalty_points"}],
|
||||||
|
group_by="redeem_against",
|
||||||
|
as_list=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,12 +52,11 @@ class ModeofPayment(Document):
|
|||||||
|
|
||||||
def validate_pos_mode_of_payment(self):
|
def validate_pos_mode_of_payment(self):
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
pos_profiles = frappe.db.sql(
|
pos_profiles = frappe.get_all(
|
||||||
"""SELECT sip.parent FROM `tabSales Invoice Payment` sip
|
"Sales Invoice Payment",
|
||||||
WHERE sip.parenttype = 'POS Profile' and sip.mode_of_payment = %s""",
|
filters={"parenttype": "POS Profile", "mode_of_payment": self.name},
|
||||||
(self.name),
|
pluck="parent",
|
||||||
)
|
)
|
||||||
pos_profiles = list(map(lambda x: x[0], pos_profiles))
|
|
||||||
|
|
||||||
if pos_profiles:
|
if pos_profiles:
|
||||||
message = _(
|
message = _(
|
||||||
|
|||||||
@@ -270,6 +270,13 @@ def start_import(invoices):
|
|||||||
errors = 0
|
errors = 0
|
||||||
names = []
|
names = []
|
||||||
for idx, d in enumerate(invoices):
|
for idx, d in enumerate(invoices):
|
||||||
|
# Scope each invoice to a savepoint so a failure only undoes that invoice.
|
||||||
|
# A plain rollback() would discard the whole transaction — including invoices
|
||||||
|
# imported earlier in this batch and the error logs of earlier failures (the
|
||||||
|
# latter only survive on mariadb because the Error Log table is MyISAM; on
|
||||||
|
# postgres they would be lost). Rolling back to a savepoint keeps both.
|
||||||
|
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
|
||||||
|
frappe.db.savepoint(savepoint)
|
||||||
try:
|
try:
|
||||||
invoice_number = None
|
invoice_number = None
|
||||||
if d.invoice_number:
|
if d.invoice_number:
|
||||||
@@ -284,7 +291,7 @@ def start_import(invoices):
|
|||||||
names.append(doc.name)
|
names.append(doc.name)
|
||||||
except Exception:
|
except Exception:
|
||||||
errors += 1
|
errors += 1
|
||||||
frappe.db.rollback()
|
frappe.db.rollback(save_point=savepoint)
|
||||||
doc.log_error("Opening invoice creation failed")
|
doc.log_error("Opening invoice creation failed")
|
||||||
if errors:
|
if errors:
|
||||||
frappe.msgprint(
|
frappe.msgprint(
|
||||||
|
|||||||
@@ -1726,6 +1726,35 @@ frappe.ui.form.on("Payment Entry", {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
before_cancel: function (frm) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
frappe.call({
|
||||||
|
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
|
||||||
|
args: { payment_entry: frm.doc.name },
|
||||||
|
callback: function (r) {
|
||||||
|
const linked = r.message || [];
|
||||||
|
if (!linked.length) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bt_links = linked
|
||||||
|
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
|
||||||
|
.join(", ");
|
||||||
|
frappe.confirm(
|
||||||
|
__(
|
||||||
|
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
|
||||||
|
[bt_links]
|
||||||
|
),
|
||||||
|
() => resolve(),
|
||||||
|
() => reject(),
|
||||||
|
__("Yes"),
|
||||||
|
__("No")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
frappe.ui.form.on("Payment Entry Reference", {
|
frappe.ui.form.on("Payment Entry Reference", {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import frappe
|
|||||||
from frappe import ValidationError, _, qb, scrub, throw
|
from frappe import ValidationError, _, qb, scrub, throw
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.model.meta import get_field_precision
|
from frappe.model.meta import get_field_precision
|
||||||
from frappe.query_builder import Tuple
|
from frappe.query_builder import Case, Tuple
|
||||||
from frappe.query_builder.functions import Count
|
from frappe.query_builder.functions import Abs, Count, Max
|
||||||
from frappe.utils import cint, comma_or, flt, getdate, nowdate
|
from frappe.utils import cint, comma_or, flt, getdate, nowdate
|
||||||
from frappe.utils.data import comma_and, fmt_money, get_link_to_form
|
from frappe.utils.data import comma_and, fmt_money, get_link_to_form
|
||||||
from pypika.functions import Coalesce, Sum
|
from pypika.functions import Coalesce, Sum
|
||||||
@@ -208,6 +208,7 @@ class PaymentEntry(AccountsController):
|
|||||||
self.make_gl_entries()
|
self.make_gl_entries()
|
||||||
self.update_outstanding_amounts()
|
self.update_outstanding_amounts()
|
||||||
self.set_status()
|
self.set_status()
|
||||||
|
self.trigger_invoice_update_for_subscriptions()
|
||||||
|
|
||||||
def validate_for_repost(self):
|
def validate_for_repost(self):
|
||||||
validate_docs_for_voucher_types(["Payment Entry"])
|
validate_docs_for_voucher_types(["Payment Entry"])
|
||||||
@@ -314,6 +315,7 @@ class PaymentEntry(AccountsController):
|
|||||||
self.update_outstanding_amounts()
|
self.update_outstanding_amounts()
|
||||||
self.delink_advance_entry_references()
|
self.delink_advance_entry_references()
|
||||||
self.set_status()
|
self.set_status()
|
||||||
|
self.trigger_invoice_update_for_subscriptions()
|
||||||
|
|
||||||
def update_payment_requests(self, cancel=False):
|
def update_payment_requests(self, cancel=False):
|
||||||
from erpnext.accounts.doctype.payment_request.payment_request import (
|
from erpnext.accounts.doctype.payment_request.payment_request import (
|
||||||
@@ -505,6 +507,19 @@ class PaymentEntry(AccountsController):
|
|||||||
doc = frappe.get_lazy_doc(reference.reference_doctype, reference.reference_name)
|
doc = frappe.get_lazy_doc(reference.reference_doctype, reference.reference_name)
|
||||||
doc.delink_advance_entries(self.name)
|
doc.delink_advance_entries(self.name)
|
||||||
|
|
||||||
|
def trigger_invoice_update_for_subscriptions(self):
|
||||||
|
invoice_names = set()
|
||||||
|
for ref in self.references:
|
||||||
|
if ref.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||||
|
invoice_names.add((ref.reference_doctype, ref.reference_name))
|
||||||
|
|
||||||
|
for doctype, name in invoice_names:
|
||||||
|
try:
|
||||||
|
doc = frappe.get_doc(doctype, name)
|
||||||
|
doc.refresh_subscription_status()
|
||||||
|
except Exception:
|
||||||
|
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
|
||||||
|
|
||||||
def set_missing_values(self):
|
def set_missing_values(self):
|
||||||
if self.payment_type == "Internal Transfer":
|
if self.payment_type == "Internal Transfer":
|
||||||
for field in (
|
for field in (
|
||||||
@@ -751,13 +766,19 @@ class PaymentEntry(AccountsController):
|
|||||||
def validate_journal_entry(self):
|
def validate_journal_entry(self):
|
||||||
for d in self.get("references"):
|
for d in self.get("references"):
|
||||||
if d.allocated_amount and d.reference_doctype == "Journal Entry":
|
if d.allocated_amount and d.reference_doctype == "Journal Entry":
|
||||||
je_accounts = frappe.db.sql(
|
je_accounts = frappe.get_all(
|
||||||
"""select debit, credit from `tabJournal Entry Account`
|
"Journal Entry Account",
|
||||||
where account = %s and party=%s and docstatus = 1 and parent = %s
|
filters={
|
||||||
and (reference_type is null or reference_type in ("", "Sales Order", "Purchase Order"))
|
"account": self.party_account,
|
||||||
""",
|
"party": self.party,
|
||||||
(self.party_account, self.party, d.reference_name),
|
"docstatus": 1,
|
||||||
as_dict=True,
|
"parent": d.reference_name,
|
||||||
|
},
|
||||||
|
or_filters=[
|
||||||
|
["reference_type", "is", "not set"],
|
||||||
|
["reference_type", "in", ["Sales Order", "Purchase Order"]],
|
||||||
|
],
|
||||||
|
fields=["debit", "credit"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if not je_accounts:
|
if not je_accounts:
|
||||||
@@ -842,27 +863,17 @@ class PaymentEntry(AccountsController):
|
|||||||
)
|
)
|
||||||
base_outstanding = flt(allocated_amount * conversion_rate, base_outstanding_precision)
|
base_outstanding = flt(allocated_amount * conversion_rate, base_outstanding_precision)
|
||||||
|
|
||||||
|
ps = frappe.qb.DocType("Payment Schedule")
|
||||||
if cancel:
|
if cancel:
|
||||||
frappe.db.sql(
|
(
|
||||||
"""
|
frappe.qb.update(ps)
|
||||||
UPDATE `tabPayment Schedule`
|
.set(ps.paid_amount, ps.paid_amount - (allocated_amount - discounted_amt))
|
||||||
SET
|
.set(ps.base_paid_amount, ps.base_paid_amount - base_paid_amount)
|
||||||
paid_amount = `paid_amount` - %s,
|
.set(ps.discounted_amount, ps.discounted_amount - discounted_amt)
|
||||||
base_paid_amount = `base_paid_amount` - %s,
|
.set(ps.outstanding, ps.outstanding + allocated_amount)
|
||||||
discounted_amount = `discounted_amount` - %s,
|
.set(ps.base_outstanding, ps.base_outstanding - base_outstanding)
|
||||||
outstanding = `outstanding` + %s,
|
.where((ps.parent == key[1]) & (ps.payment_term == key[0]))
|
||||||
base_outstanding = `base_outstanding` - %s
|
).run()
|
||||||
WHERE parent = %s and payment_term = %s""",
|
|
||||||
(
|
|
||||||
allocated_amount - discounted_amt,
|
|
||||||
base_paid_amount,
|
|
||||||
discounted_amt,
|
|
||||||
allocated_amount,
|
|
||||||
base_outstanding,
|
|
||||||
key[1],
|
|
||||||
key[0],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if allocated_amount > outstanding:
|
if allocated_amount > outstanding:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
@@ -872,26 +883,15 @@ class PaymentEntry(AccountsController):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if allocated_amount and outstanding:
|
if allocated_amount and outstanding:
|
||||||
frappe.db.sql(
|
(
|
||||||
"""
|
frappe.qb.update(ps)
|
||||||
UPDATE `tabPayment Schedule`
|
.set(ps.paid_amount, ps.paid_amount + (allocated_amount - discounted_amt))
|
||||||
SET
|
.set(ps.base_paid_amount, ps.base_paid_amount + base_paid_amount)
|
||||||
paid_amount = `paid_amount` + %s,
|
.set(ps.discounted_amount, ps.discounted_amount + discounted_amt)
|
||||||
base_paid_amount = `base_paid_amount` + %s,
|
.set(ps.outstanding, ps.outstanding - allocated_amount)
|
||||||
discounted_amount = `discounted_amount` + %s,
|
.set(ps.base_outstanding, ps.base_outstanding - base_outstanding)
|
||||||
outstanding = `outstanding` - %s,
|
.where((ps.parent == key[1]) & (ps.payment_term == key[0]))
|
||||||
base_outstanding = `base_outstanding` - %s
|
).run()
|
||||||
WHERE parent = %s and payment_term = %s""",
|
|
||||||
(
|
|
||||||
allocated_amount - discounted_amt,
|
|
||||||
base_paid_amount,
|
|
||||||
discounted_amt,
|
|
||||||
allocated_amount,
|
|
||||||
base_outstanding,
|
|
||||||
key[1],
|
|
||||||
key[0],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_allocated_amount_in_transaction_currency(
|
def get_allocated_amount_in_transaction_currency(
|
||||||
self, allocated_amount, reference_doctype, reference_docname
|
self, allocated_amount, reference_doctype, reference_docname
|
||||||
@@ -1191,9 +1191,9 @@ class PaymentEntry(AccountsController):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if tax.add_deduct_tax == "Add":
|
if tax.add_deduct_tax == "Add":
|
||||||
included_taxes += tax.base_tax_amount
|
included_taxes += flt(tax.base_tax_amount)
|
||||||
else:
|
else:
|
||||||
included_taxes -= tax.base_tax_amount
|
included_taxes -= flt(tax.base_tax_amount)
|
||||||
|
|
||||||
return included_taxes
|
return included_taxes
|
||||||
|
|
||||||
@@ -1201,11 +1201,7 @@ class PaymentEntry(AccountsController):
|
|||||||
# Clear the reference document which doesn't have allocated amount on validate so that form can be loaded fast
|
# Clear the reference document which doesn't have allocated amount on validate so that form can be loaded fast
|
||||||
def clear_unallocated_reference_document_rows(self):
|
def clear_unallocated_reference_document_rows(self):
|
||||||
self.set("references", self.get("references", {"allocated_amount": ["not in", [0, None, ""]]}))
|
self.set("references", self.get("references", {"allocated_amount": ["not in", [0, None, ""]]}))
|
||||||
frappe.db.sql(
|
frappe.db.delete("Payment Entry Reference", {"parent": self.name, "allocated_amount": 0})
|
||||||
"""delete from `tabPayment Entry Reference`
|
|
||||||
where parent = %s and allocated_amount = 0""",
|
|
||||||
self.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_title(self):
|
def set_title(self):
|
||||||
if frappe.flags.in_import and self.title:
|
if frappe.flags.in_import and self.title:
|
||||||
@@ -1287,17 +1283,9 @@ class PaymentEntry(AccountsController):
|
|||||||
self.transaction_exchange_rate = self.target_exchange_rate
|
self.transaction_exchange_rate = self.target_exchange_rate
|
||||||
|
|
||||||
def build_gl_map(self):
|
def build_gl_map(self):
|
||||||
if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
|
from erpnext.accounts.doctype.payment_entry.services.gl_composer import PaymentEntryGLComposer
|
||||||
self.setup_party_account_field()
|
|
||||||
self.set_transaction_currency_and_rate()
|
|
||||||
|
|
||||||
gl_entries = []
|
return PaymentEntryGLComposer(self).compose()
|
||||||
self.add_party_gl_entries(gl_entries)
|
|
||||||
self.add_bank_gl_entries(gl_entries)
|
|
||||||
self.add_deductions_gl_entries(gl_entries)
|
|
||||||
self.add_tax_gl_entries(gl_entries)
|
|
||||||
add_regional_gl_entries(gl_entries, self)
|
|
||||||
return gl_entries
|
|
||||||
|
|
||||||
def make_gl_entries(self, cancel=0, adv_adj=0):
|
def make_gl_entries(self, cancel=0, adv_adj=0):
|
||||||
gl_entries = self.build_gl_map()
|
gl_entries = self.build_gl_map()
|
||||||
@@ -1313,132 +1301,6 @@ class PaymentEntry(AccountsController):
|
|||||||
|
|
||||||
self.make_advance_gl_entries(cancel=cancel)
|
self.make_advance_gl_entries(cancel=cancel)
|
||||||
|
|
||||||
def add_party_gl_entries(self, gl_entries):
|
|
||||||
if not self.party_account:
|
|
||||||
return
|
|
||||||
|
|
||||||
advance_payment_doctypes = get_advance_payment_doctypes()
|
|
||||||
if self.payment_type == "Receive":
|
|
||||||
against_account = self.paid_to
|
|
||||||
else:
|
|
||||||
against_account = self.paid_from
|
|
||||||
|
|
||||||
party_account_type = frappe.db.get_value("Party Type", self.party_type, "account_type")
|
|
||||||
|
|
||||||
party_gl_dict = self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": self.party_account,
|
|
||||||
"party_type": self.party_type,
|
|
||||||
"party": self.party,
|
|
||||||
"against": against_account,
|
|
||||||
"account_currency": self.party_account_currency,
|
|
||||||
"cost_center": self.cost_center,
|
|
||||||
},
|
|
||||||
item=self,
|
|
||||||
)
|
|
||||||
|
|
||||||
for d in self.get("references"):
|
|
||||||
# re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
|
|
||||||
dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
|
|
||||||
cost_center = self.cost_center
|
|
||||||
if d.reference_doctype == "Sales Invoice" and not cost_center:
|
|
||||||
cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
|
|
||||||
|
|
||||||
gle = party_gl_dict.copy()
|
|
||||||
|
|
||||||
allocated_amount_in_company_currency = self.calculate_base_allocated_amount_for_reference(d)
|
|
||||||
|
|
||||||
if (
|
|
||||||
d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
|
|
||||||
and d.allocated_amount < 0
|
|
||||||
and (
|
|
||||||
(party_account_type == "Receivable" and self.payment_type == "Pay")
|
|
||||||
or (party_account_type == "Payable" and self.payment_type == "Receive")
|
|
||||||
)
|
|
||||||
):
|
|
||||||
# reversing dr_cr because because it will get reversed in gl processing due to negative amount
|
|
||||||
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
|
|
||||||
|
|
||||||
gle.update(
|
|
||||||
self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": self.party_account,
|
|
||||||
"party_type": self.party_type,
|
|
||||||
"party": self.party,
|
|
||||||
"against": against_account,
|
|
||||||
"account_currency": self.party_account_currency,
|
|
||||||
"cost_center": cost_center,
|
|
||||||
dr_or_cr + "_in_account_currency": d.allocated_amount,
|
|
||||||
dr_or_cr: allocated_amount_in_company_currency,
|
|
||||||
dr_or_cr + "_in_transaction_currency": d.allocated_amount
|
|
||||||
if self.transaction_currency == self.party_account_currency
|
|
||||||
else allocated_amount_in_company_currency / self.transaction_exchange_rate,
|
|
||||||
"advance_voucher_type": d.advance_voucher_type,
|
|
||||||
"advance_voucher_no": d.advance_voucher_no,
|
|
||||||
"transaction_exchange_rate": self.target_exchange_rate,
|
|
||||||
},
|
|
||||||
item=self,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if d.reference_doctype in advance_payment_doctypes:
|
|
||||||
# advance reference
|
|
||||||
gle.update(
|
|
||||||
{
|
|
||||||
"against_voucher_type": self.doctype,
|
|
||||||
"against_voucher": self.name,
|
|
||||||
"advance_voucher_type": d.reference_doctype,
|
|
||||||
"advance_voucher_no": d.reference_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
elif self.book_advance_payments_in_separate_party_account:
|
|
||||||
# Do not reference Invoices while Advance is in separate party account
|
|
||||||
gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name})
|
|
||||||
else:
|
|
||||||
gle.update(
|
|
||||||
{
|
|
||||||
"against_voucher_type": d.reference_doctype,
|
|
||||||
"against_voucher": d.reference_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
gl_entries.append(gle)
|
|
||||||
|
|
||||||
if self.unallocated_amount:
|
|
||||||
dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
|
|
||||||
exchange_rate = self.get_exchange_rate()
|
|
||||||
base_unallocated_amount = self.unallocated_amount * exchange_rate
|
|
||||||
|
|
||||||
gle = party_gl_dict.copy()
|
|
||||||
|
|
||||||
gle.update(
|
|
||||||
self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": self.party_account,
|
|
||||||
"party_type": self.party_type,
|
|
||||||
"party": self.party,
|
|
||||||
"against": against_account,
|
|
||||||
"account_currency": self.party_account_currency,
|
|
||||||
"cost_center": self.cost_center,
|
|
||||||
dr_or_cr + "_in_account_currency": self.unallocated_amount,
|
|
||||||
dr_or_cr + "_in_transaction_currency": self.unallocated_amount
|
|
||||||
if self.party_account_currency == self.transaction_currency
|
|
||||||
else base_unallocated_amount / self.transaction_exchange_rate,
|
|
||||||
dr_or_cr: base_unallocated_amount,
|
|
||||||
},
|
|
||||||
item=self,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if self.book_advance_payments_in_separate_party_account:
|
|
||||||
gle.update(
|
|
||||||
{
|
|
||||||
"against_voucher_type": "Payment Entry",
|
|
||||||
"against_voucher": self.name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
gl_entries.append(gle)
|
|
||||||
|
|
||||||
def make_advance_gl_entries(
|
def make_advance_gl_entries(
|
||||||
self, entry: object | dict = None, cancel: bool = 0, update_outstanding: str = "Yes"
|
self, entry: object | dict = None, cancel: bool = 0, update_outstanding: str = "Yes"
|
||||||
):
|
):
|
||||||
@@ -1560,132 +1422,6 @@ class PaymentEntry(AccountsController):
|
|||||||
)
|
)
|
||||||
gl_entries.append(gle)
|
gl_entries.append(gle)
|
||||||
|
|
||||||
def add_bank_gl_entries(self, gl_entries):
|
|
||||||
if self.payment_type in ("Pay", "Internal Transfer"):
|
|
||||||
gl_entries.append(
|
|
||||||
self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": self.paid_from,
|
|
||||||
"account_currency": self.paid_from_account_currency,
|
|
||||||
"against": self.party if self.payment_type == "Pay" else self.paid_to,
|
|
||||||
"credit_in_account_currency": self.paid_amount,
|
|
||||||
"credit_in_transaction_currency": self.paid_amount
|
|
||||||
if self.paid_from_account_currency == self.transaction_currency
|
|
||||||
else self.base_paid_amount / self.transaction_exchange_rate,
|
|
||||||
"credit": self.base_paid_amount,
|
|
||||||
"cost_center": self.cost_center,
|
|
||||||
"post_net_value": True,
|
|
||||||
},
|
|
||||||
item=self,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if self.payment_type in ("Receive", "Internal Transfer"):
|
|
||||||
gl_entries.append(
|
|
||||||
self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": self.paid_to,
|
|
||||||
"account_currency": self.paid_to_account_currency,
|
|
||||||
"against": self.party if self.payment_type == "Receive" else self.paid_from,
|
|
||||||
"debit_in_account_currency": self.received_amount,
|
|
||||||
"debit_in_transaction_currency": self.received_amount
|
|
||||||
if self.paid_to_account_currency == self.transaction_currency
|
|
||||||
else self.base_received_amount / self.transaction_exchange_rate,
|
|
||||||
"debit": self.base_received_amount,
|
|
||||||
"cost_center": self.cost_center,
|
|
||||||
},
|
|
||||||
item=self,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_tax_gl_entries(self, gl_entries):
|
|
||||||
for d in self.get("taxes"):
|
|
||||||
account_currency = get_account_currency(d.account_head)
|
|
||||||
if account_currency != self.company_currency:
|
|
||||||
frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency))
|
|
||||||
|
|
||||||
if self.payment_type in ("Pay", "Internal Transfer"):
|
|
||||||
dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
|
|
||||||
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
|
||||||
against = self.party or self.paid_from
|
|
||||||
elif self.payment_type == "Receive":
|
|
||||||
dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
|
|
||||||
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
|
||||||
against = self.party or self.paid_to
|
|
||||||
|
|
||||||
payment_account = self.get_party_account_for_taxes()
|
|
||||||
tax_amount = d.tax_amount
|
|
||||||
base_tax_amount = d.base_tax_amount
|
|
||||||
|
|
||||||
gl_entries.append(
|
|
||||||
self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": d.account_head,
|
|
||||||
"against": against,
|
|
||||||
dr_or_cr: tax_amount,
|
|
||||||
dr_or_cr + "_in_account_currency": base_tax_amount
|
|
||||||
if account_currency == self.company_currency
|
|
||||||
else d.tax_amount,
|
|
||||||
dr_or_cr + "_in_transaction_currency": base_tax_amount
|
|
||||||
/ self.transaction_exchange_rate,
|
|
||||||
"cost_center": d.cost_center,
|
|
||||||
"post_net_value": True,
|
|
||||||
},
|
|
||||||
account_currency,
|
|
||||||
item=d,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not d.included_in_paid_amount:
|
|
||||||
if get_account_currency(payment_account) != self.company_currency:
|
|
||||||
if self.payment_type == "Receive":
|
|
||||||
exchange_rate = self.target_exchange_rate
|
|
||||||
elif self.payment_type in ["Pay", "Internal Transfer"]:
|
|
||||||
exchange_rate = self.source_exchange_rate
|
|
||||||
base_tax_amount = flt((tax_amount / exchange_rate), self.precision("paid_amount"))
|
|
||||||
|
|
||||||
gl_entries.append(
|
|
||||||
self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": payment_account,
|
|
||||||
"against": against,
|
|
||||||
rev_dr_or_cr: tax_amount,
|
|
||||||
rev_dr_or_cr + "_in_account_currency": base_tax_amount
|
|
||||||
if account_currency == self.company_currency
|
|
||||||
else d.tax_amount,
|
|
||||||
rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
|
|
||||||
/ self.transaction_exchange_rate,
|
|
||||||
"cost_center": self.cost_center,
|
|
||||||
"post_net_value": True,
|
|
||||||
},
|
|
||||||
account_currency,
|
|
||||||
item=d,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_deductions_gl_entries(self, gl_entries):
|
|
||||||
for d in self.get("deductions"):
|
|
||||||
if not d.amount:
|
|
||||||
continue
|
|
||||||
|
|
||||||
account_currency = get_account_currency(d.account)
|
|
||||||
if account_currency != self.company_currency:
|
|
||||||
frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))
|
|
||||||
|
|
||||||
gl_entries.append(
|
|
||||||
self.get_gl_dict(
|
|
||||||
{
|
|
||||||
"account": d.account,
|
|
||||||
"account_currency": account_currency,
|
|
||||||
"against": self.party or self.paid_from,
|
|
||||||
"debit_in_account_currency": d.amount,
|
|
||||||
"debit_in_transaction_currency": d.amount / self.transaction_exchange_rate,
|
|
||||||
"debit": d.amount,
|
|
||||||
"cost_center": d.cost_center,
|
|
||||||
},
|
|
||||||
item=d,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_party_account_for_taxes(self):
|
def get_party_account_for_taxes(self):
|
||||||
if self.payment_type == "Receive":
|
if self.payment_type == "Receive":
|
||||||
return self.paid_to
|
return self.paid_to
|
||||||
@@ -2121,7 +1857,7 @@ def get_matched_payment_request_of_references(references=None):
|
|||||||
PR.reference_doctype,
|
PR.reference_doctype,
|
||||||
PR.reference_name,
|
PR.reference_name,
|
||||||
PR.outstanding_amount.as_("allocated_amount"),
|
PR.outstanding_amount.as_("allocated_amount"),
|
||||||
PR.name.as_("payment_request"),
|
Max(PR.name).as_("payment_request"), # count == 1 below ⇒ one row per group; postgres-safe
|
||||||
Count("*").as_("count"),
|
Count("*").as_("count"),
|
||||||
)
|
)
|
||||||
.where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
|
.where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
|
||||||
@@ -2281,6 +2017,9 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
|||||||
if args.get("party_type") == "Member":
|
if args.get("party_type") == "Member":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.get("party_type") and args.get("party"):
|
||||||
|
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
|
||||||
|
|
||||||
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
|
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
|
||||||
args["get_outstanding_invoices"] = True
|
args["get_outstanding_invoices"] = True
|
||||||
|
|
||||||
@@ -2557,12 +2296,7 @@ def get_orders_to_be_billed(
|
|||||||
if not voucher_type:
|
if not voucher_type:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# dynamic dimension filters
|
|
||||||
condition = ""
|
|
||||||
active_dimensions = get_dimensions(True)[0]
|
active_dimensions = get_dimensions(True)[0]
|
||||||
for dim in active_dimensions:
|
|
||||||
if filters.get(dim.fieldname):
|
|
||||||
condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}"
|
|
||||||
|
|
||||||
if party_account_currency == company_currency:
|
if party_account_currency == company_currency:
|
||||||
grand_total_field = "base_grand_total"
|
grand_total_field = "base_grand_total"
|
||||||
@@ -2571,38 +2305,38 @@ def get_orders_to_be_billed(
|
|||||||
grand_total_field = "grand_total"
|
grand_total_field = "grand_total"
|
||||||
rounded_total_field = "rounded_total"
|
rounded_total_field = "rounded_total"
|
||||||
|
|
||||||
orders = frappe.db.sql(
|
voucher = frappe.qb.DocType(voucher_type)
|
||||||
"""
|
invoice_amount = (
|
||||||
select
|
Case()
|
||||||
name as voucher_no,
|
.when(voucher[rounded_total_field] != 0, voucher[rounded_total_field])
|
||||||
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
|
.else_(voucher[grand_total_field])
|
||||||
(if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) - advance_paid) as outstanding_amount,
|
|
||||||
transaction_date as posting_date
|
|
||||||
from
|
|
||||||
`tab{voucher_type}`
|
|
||||||
where
|
|
||||||
{party_type} = %s
|
|
||||||
and docstatus = 1
|
|
||||||
and company = %s
|
|
||||||
and status != "Closed"
|
|
||||||
and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid
|
|
||||||
and abs(100 - per_billed) > 0.01
|
|
||||||
{condition}
|
|
||||||
order by
|
|
||||||
transaction_date, name
|
|
||||||
""".format(
|
|
||||||
**{
|
|
||||||
"rounded_total_field": rounded_total_field,
|
|
||||||
"grand_total_field": grand_total_field,
|
|
||||||
"voucher_type": voucher_type,
|
|
||||||
"party_type": scrub(party_type),
|
|
||||||
"condition": condition,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(party, company),
|
|
||||||
as_dict=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
frappe.qb.from_(voucher)
|
||||||
|
.select(
|
||||||
|
voucher.name.as_("voucher_no"),
|
||||||
|
invoice_amount.as_("invoice_amount"),
|
||||||
|
(invoice_amount - voucher.advance_paid).as_("outstanding_amount"),
|
||||||
|
voucher.transaction_date.as_("posting_date"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(voucher[scrub(party_type)] == party)
|
||||||
|
& (voucher.docstatus == 1)
|
||||||
|
& (voucher.company == company)
|
||||||
|
& (voucher.status != "Closed")
|
||||||
|
& (invoice_amount > voucher.advance_paid)
|
||||||
|
& (Abs(100 - voucher.per_billed) > 0.01)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# dynamic dimension filters
|
||||||
|
for dim in active_dimensions:
|
||||||
|
if filters.get(dim.fieldname):
|
||||||
|
query = query.where(voucher[dim.fieldname] == filters.get(dim.fieldname))
|
||||||
|
|
||||||
|
orders = query.orderby(voucher.transaction_date).orderby(voucher.name).run(as_dict=True)
|
||||||
|
|
||||||
order_list = []
|
order_list = []
|
||||||
for d in orders:
|
for d in orders:
|
||||||
if (
|
if (
|
||||||
@@ -2651,8 +2385,8 @@ def get_negative_outstanding_invoices(
|
|||||||
return frappe.db.sql(
|
return frappe.db.sql(
|
||||||
"""
|
"""
|
||||||
select
|
select
|
||||||
"{voucher_type}" as voucher_type, name as voucher_no, {account} as account,
|
'{voucher_type}' as voucher_type, name as voucher_no, {account} as account,
|
||||||
if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) as invoice_amount,
|
coalesce(nullif({rounded_total_field}, 0), {grand_total_field}) as invoice_amount,
|
||||||
outstanding_amount, posting_date,
|
outstanding_amount, posting_date,
|
||||||
due_date, conversion_rate as exchange_rate
|
due_date, conversion_rate as exchange_rate
|
||||||
from
|
from
|
||||||
@@ -2776,6 +2510,7 @@ def get_reference_details(
|
|||||||
):
|
):
|
||||||
total_amount = outstanding_amount = exchange_rate = account = None
|
total_amount = outstanding_amount = exchange_rate = account = None
|
||||||
|
|
||||||
|
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||||
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
|
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
|
||||||
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
|
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
|
||||||
|
|
||||||
@@ -3021,7 +2756,7 @@ def get_payment_entry(
|
|||||||
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
|
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
|
||||||
)
|
)
|
||||||
|
|
||||||
pe.set_exchange_rate(ref_doc=doc)
|
pe.set_exchange_rate()
|
||||||
pe.set_amounts()
|
pe.set_amounts()
|
||||||
|
|
||||||
# If PE is created from PR directly, then no need to find open PRs for the references
|
# If PE is created from PR directly, then no need to find open PRs for the references
|
||||||
@@ -3513,27 +3248,28 @@ def get_reference_as_per_payment_terms(
|
|||||||
|
|
||||||
|
|
||||||
def get_paid_amount(dt, dn, party_type, party, account, due_date):
|
def get_paid_amount(dt, dn, party_type, party, account, due_date):
|
||||||
|
gle = frappe.qb.DocType("GL Entry")
|
||||||
if party_type == "Customer":
|
if party_type == "Customer":
|
||||||
dr_or_cr = "credit_in_account_currency - debit_in_account_currency"
|
dr_or_cr = gle.credit_in_account_currency - gle.debit_in_account_currency
|
||||||
else:
|
else:
|
||||||
dr_or_cr = "debit_in_account_currency - credit_in_account_currency"
|
dr_or_cr = gle.debit_in_account_currency - gle.credit_in_account_currency
|
||||||
|
|
||||||
paid_amount = frappe.db.sql(
|
paid_amount = (
|
||||||
f"""
|
frappe.qb.from_(gle)
|
||||||
select ifnull(sum({dr_or_cr}), 0) as paid_amount
|
.select(Sum(dr_or_cr))
|
||||||
from `tabGL Entry`
|
.where(
|
||||||
where against_voucher_type = %s
|
(gle.against_voucher_type == dt)
|
||||||
and against_voucher = %s
|
& (gle.against_voucher == dn)
|
||||||
and party_type = %s
|
& (gle.party_type == party_type)
|
||||||
and party = %s
|
& (gle.party == party)
|
||||||
and account = %s
|
& (gle.account == account)
|
||||||
and due_date = %s
|
& (gle.due_date == due_date)
|
||||||
and {dr_or_cr} > 0
|
& (dr_or_cr > 0)
|
||||||
""",
|
)
|
||||||
(dt, dn, party_type, party, account, due_date),
|
.run()
|
||||||
)
|
)
|
||||||
|
|
||||||
return paid_amount[0][0] if paid_amount else 0
|
return (paid_amount[0][0] or 0) if paid_amount else 0
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -3574,3 +3310,16 @@ def make_payment_order(source_name: str, target_doc: str | Document | None = Non
|
|||||||
@erpnext.allow_regional
|
@erpnext.allow_regional
|
||||||
def add_regional_gl_entries(gl_entries, doc):
|
def add_regional_gl_entries(gl_entries, doc):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_linked_bank_transactions(payment_entry: str) -> list:
|
||||||
|
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
|
||||||
|
return frappe.get_all(
|
||||||
|
"Bank Transaction Payments",
|
||||||
|
filters={
|
||||||
|
"payment_document": "Payment Entry",
|
||||||
|
"payment_entry": payment_entry,
|
||||||
|
},
|
||||||
|
pluck="parent",
|
||||||
|
)
|
||||||
|
|||||||
299
erpnext/accounts/doctype/payment_entry/services/gl_composer.py
Normal file
299
erpnext/accounts/doctype/payment_entry/services/gl_composer.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.utils import flt
|
||||||
|
|
||||||
|
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
|
||||||
|
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentEntryGLComposer(BaseGLComposer):
|
||||||
|
"""Assembles the GL entries for a Payment Entry.
|
||||||
|
|
||||||
|
The voucher-specific row builders live here and operate on ``self.doc``.
|
||||||
|
Shared helpers (get_gl_dict, calculate_base_allocated_amount_for_reference,
|
||||||
|
get_exchange_rate, get_party_account_for_taxes) remain on the document for
|
||||||
|
now and are invoked via ``self.doc``. The advance-posting builders stay on
|
||||||
|
the document; they post separately from this compose pass and move with the
|
||||||
|
advances service in a later phase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self):
|
||||||
|
from erpnext.accounts.doctype.payment_entry.payment_entry import add_regional_gl_entries
|
||||||
|
|
||||||
|
doc = self.doc
|
||||||
|
if doc.payment_type in ("Receive", "Pay") and not doc.get("party_account_field"):
|
||||||
|
doc.setup_party_account_field()
|
||||||
|
doc.set_transaction_currency_and_rate()
|
||||||
|
|
||||||
|
gl_entries = []
|
||||||
|
self.add_party_gl_entries(gl_entries)
|
||||||
|
self.add_bank_gl_entries(gl_entries)
|
||||||
|
self.add_deductions_gl_entries(gl_entries)
|
||||||
|
self.add_tax_gl_entries(gl_entries)
|
||||||
|
add_regional_gl_entries(gl_entries, doc)
|
||||||
|
self.set_transaction_currency_and_rate_in_gl_map(gl_entries, doc)
|
||||||
|
return gl_entries
|
||||||
|
|
||||||
|
def set_transaction_currency_and_rate_in_gl_map(self, gl_entries, doc):
|
||||||
|
for gle in gl_entries:
|
||||||
|
gle.setdefault("transaction_currency", doc.transaction_currency)
|
||||||
|
gle.setdefault("transaction_exchange_rate", doc.transaction_exchange_rate)
|
||||||
|
|
||||||
|
def add_party_gl_entries(self, gl_entries):
|
||||||
|
doc = self.doc
|
||||||
|
if not doc.party_account:
|
||||||
|
return
|
||||||
|
|
||||||
|
advance_payment_doctypes = get_advance_payment_doctypes()
|
||||||
|
if doc.payment_type == "Receive":
|
||||||
|
against_account = doc.paid_to
|
||||||
|
else:
|
||||||
|
against_account = doc.paid_from
|
||||||
|
|
||||||
|
party_account_type = frappe.db.get_value("Party Type", doc.party_type, "account_type")
|
||||||
|
|
||||||
|
party_gl_dict = self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": doc.party_account,
|
||||||
|
"party_type": doc.party_type,
|
||||||
|
"party": doc.party,
|
||||||
|
"against": against_account,
|
||||||
|
"account_currency": doc.party_account_currency,
|
||||||
|
"cost_center": doc.cost_center,
|
||||||
|
},
|
||||||
|
item=doc,
|
||||||
|
)
|
||||||
|
|
||||||
|
for d in doc.get("references"):
|
||||||
|
# re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
|
||||||
|
dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
|
||||||
|
cost_center = doc.cost_center
|
||||||
|
if d.reference_doctype == "Sales Invoice" and not cost_center:
|
||||||
|
cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
|
||||||
|
|
||||||
|
gle = party_gl_dict.copy()
|
||||||
|
|
||||||
|
allocated_amount_in_company_currency = doc.calculate_base_allocated_amount_for_reference(d)
|
||||||
|
|
||||||
|
if (
|
||||||
|
d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
|
||||||
|
and d.allocated_amount < 0
|
||||||
|
and (
|
||||||
|
(party_account_type == "Receivable" and doc.payment_type == "Pay")
|
||||||
|
or (party_account_type == "Payable" and doc.payment_type == "Receive")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
# reversing dr_cr because because it will get reversed in gl processing due to negative amount
|
||||||
|
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
|
||||||
|
|
||||||
|
gle.update(
|
||||||
|
self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": doc.party_account,
|
||||||
|
"party_type": doc.party_type,
|
||||||
|
"party": doc.party,
|
||||||
|
"against": against_account,
|
||||||
|
"account_currency": doc.party_account_currency,
|
||||||
|
"cost_center": cost_center,
|
||||||
|
dr_or_cr + "_in_account_currency": d.allocated_amount,
|
||||||
|
dr_or_cr: allocated_amount_in_company_currency,
|
||||||
|
dr_or_cr + "_in_transaction_currency": d.allocated_amount
|
||||||
|
if doc.transaction_currency == doc.party_account_currency
|
||||||
|
else allocated_amount_in_company_currency / doc.transaction_exchange_rate,
|
||||||
|
"advance_voucher_type": d.advance_voucher_type,
|
||||||
|
"advance_voucher_no": d.advance_voucher_no,
|
||||||
|
"transaction_exchange_rate": doc.target_exchange_rate,
|
||||||
|
},
|
||||||
|
item=doc,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if d.reference_doctype in advance_payment_doctypes:
|
||||||
|
# advance reference
|
||||||
|
gle.update(
|
||||||
|
{
|
||||||
|
"against_voucher_type": doc.doctype,
|
||||||
|
"against_voucher": doc.name,
|
||||||
|
"advance_voucher_type": d.reference_doctype,
|
||||||
|
"advance_voucher_no": d.reference_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif doc.book_advance_payments_in_separate_party_account:
|
||||||
|
# Do not reference Invoices while Advance is in separate party account
|
||||||
|
gle.update({"against_voucher_type": doc.doctype, "against_voucher": doc.name})
|
||||||
|
else:
|
||||||
|
gle.update(
|
||||||
|
{
|
||||||
|
"against_voucher_type": d.reference_doctype,
|
||||||
|
"against_voucher": d.reference_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
gl_entries.append(gle)
|
||||||
|
|
||||||
|
if doc.unallocated_amount:
|
||||||
|
dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
|
||||||
|
exchange_rate = doc.get_exchange_rate()
|
||||||
|
base_unallocated_amount = doc.unallocated_amount * exchange_rate
|
||||||
|
|
||||||
|
gle = party_gl_dict.copy()
|
||||||
|
|
||||||
|
gle.update(
|
||||||
|
self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": doc.party_account,
|
||||||
|
"party_type": doc.party_type,
|
||||||
|
"party": doc.party,
|
||||||
|
"against": against_account,
|
||||||
|
"account_currency": doc.party_account_currency,
|
||||||
|
"cost_center": doc.cost_center,
|
||||||
|
dr_or_cr + "_in_account_currency": doc.unallocated_amount,
|
||||||
|
dr_or_cr + "_in_transaction_currency": doc.unallocated_amount
|
||||||
|
if doc.party_account_currency == doc.transaction_currency
|
||||||
|
else base_unallocated_amount / doc.transaction_exchange_rate,
|
||||||
|
dr_or_cr: base_unallocated_amount,
|
||||||
|
},
|
||||||
|
item=doc,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if doc.book_advance_payments_in_separate_party_account:
|
||||||
|
gle.update(
|
||||||
|
{
|
||||||
|
"against_voucher_type": "Payment Entry",
|
||||||
|
"against_voucher": doc.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
gl_entries.append(gle)
|
||||||
|
|
||||||
|
def add_bank_gl_entries(self, gl_entries):
|
||||||
|
doc = self.doc
|
||||||
|
if doc.payment_type in ("Pay", "Internal Transfer"):
|
||||||
|
gl_entries.append(
|
||||||
|
self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": doc.paid_from,
|
||||||
|
"account_currency": doc.paid_from_account_currency,
|
||||||
|
"against": doc.party if doc.payment_type == "Pay" else doc.paid_to,
|
||||||
|
"credit_in_account_currency": doc.paid_amount,
|
||||||
|
"credit_in_transaction_currency": doc.paid_amount
|
||||||
|
if doc.paid_from_account_currency == doc.transaction_currency
|
||||||
|
else doc.base_paid_amount / doc.transaction_exchange_rate,
|
||||||
|
"credit": doc.base_paid_amount,
|
||||||
|
"cost_center": doc.cost_center,
|
||||||
|
"post_net_value": True,
|
||||||
|
},
|
||||||
|
item=doc,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if doc.payment_type in ("Receive", "Internal Transfer"):
|
||||||
|
gl_entries.append(
|
||||||
|
self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": doc.paid_to,
|
||||||
|
"account_currency": doc.paid_to_account_currency,
|
||||||
|
"against": doc.party if doc.payment_type == "Receive" else doc.paid_from,
|
||||||
|
"debit_in_account_currency": doc.received_amount,
|
||||||
|
"debit_in_transaction_currency": doc.received_amount
|
||||||
|
if doc.paid_to_account_currency == doc.transaction_currency
|
||||||
|
else doc.base_received_amount / doc.transaction_exchange_rate,
|
||||||
|
"debit": doc.base_received_amount,
|
||||||
|
"cost_center": doc.cost_center,
|
||||||
|
},
|
||||||
|
item=doc,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_tax_gl_entries(self, gl_entries):
|
||||||
|
doc = self.doc
|
||||||
|
for d in doc.get("taxes"):
|
||||||
|
account_currency = get_account_currency(d.account_head)
|
||||||
|
if account_currency != doc.company_currency:
|
||||||
|
frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, doc.company_currency))
|
||||||
|
|
||||||
|
if doc.payment_type in ("Pay", "Internal Transfer"):
|
||||||
|
dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
|
||||||
|
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
||||||
|
against = doc.party or doc.paid_from
|
||||||
|
elif doc.payment_type == "Receive":
|
||||||
|
dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
|
||||||
|
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
|
||||||
|
against = doc.party or doc.paid_to
|
||||||
|
|
||||||
|
payment_account = doc.get_party_account_for_taxes()
|
||||||
|
tax_amount = d.tax_amount
|
||||||
|
base_tax_amount = d.base_tax_amount
|
||||||
|
|
||||||
|
gl_entries.append(
|
||||||
|
self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": d.account_head,
|
||||||
|
"against": against,
|
||||||
|
dr_or_cr: tax_amount,
|
||||||
|
dr_or_cr + "_in_account_currency": base_tax_amount
|
||||||
|
if account_currency == doc.company_currency
|
||||||
|
else d.tax_amount,
|
||||||
|
dr_or_cr + "_in_transaction_currency": base_tax_amount
|
||||||
|
/ doc.transaction_exchange_rate,
|
||||||
|
"cost_center": d.cost_center,
|
||||||
|
"post_net_value": True,
|
||||||
|
},
|
||||||
|
account_currency,
|
||||||
|
item=d,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not d.included_in_paid_amount:
|
||||||
|
if get_account_currency(payment_account) != doc.company_currency:
|
||||||
|
if doc.payment_type == "Receive":
|
||||||
|
exchange_rate = doc.target_exchange_rate
|
||||||
|
elif doc.payment_type in ["Pay", "Internal Transfer"]:
|
||||||
|
exchange_rate = doc.source_exchange_rate
|
||||||
|
base_tax_amount = flt((tax_amount / exchange_rate), doc.precision("paid_amount"))
|
||||||
|
|
||||||
|
gl_entries.append(
|
||||||
|
self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": payment_account,
|
||||||
|
"against": against,
|
||||||
|
rev_dr_or_cr: tax_amount,
|
||||||
|
rev_dr_or_cr + "_in_account_currency": base_tax_amount
|
||||||
|
if account_currency == doc.company_currency
|
||||||
|
else d.tax_amount,
|
||||||
|
rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
|
||||||
|
/ doc.transaction_exchange_rate,
|
||||||
|
"cost_center": doc.cost_center,
|
||||||
|
"post_net_value": True,
|
||||||
|
},
|
||||||
|
account_currency,
|
||||||
|
item=d,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_deductions_gl_entries(self, gl_entries):
|
||||||
|
doc = self.doc
|
||||||
|
for d in doc.get("deductions"):
|
||||||
|
if not d.amount:
|
||||||
|
continue
|
||||||
|
|
||||||
|
account_currency = get_account_currency(d.account)
|
||||||
|
if account_currency != doc.company_currency:
|
||||||
|
frappe.throw(_("Currency for {0} must be {1}").format(d.account, doc.company_currency))
|
||||||
|
|
||||||
|
gl_entries.append(
|
||||||
|
self.get_gl_dict(
|
||||||
|
{
|
||||||
|
"account": d.account,
|
||||||
|
"account_currency": account_currency,
|
||||||
|
"against": doc.party or doc.paid_from,
|
||||||
|
"debit_in_account_currency": d.amount,
|
||||||
|
"debit_in_transaction_currency": d.amount / doc.transaction_exchange_rate,
|
||||||
|
"debit": d.amount,
|
||||||
|
"cost_center": d.cost_center,
|
||||||
|
},
|
||||||
|
item=d,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -196,7 +196,7 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
self.assertEqual(outstanding_amount, 100)
|
self.assertEqual(outstanding_amount, 100)
|
||||||
|
|
||||||
def test_reference_outstanding_amount_on_advance_pull(self):
|
def test_reference_outstanding_amount_on_advance_pull(self):
|
||||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
|
||||||
|
|
||||||
so = make_sales_order(qty=1, rate=1000)
|
so = make_sales_order(qty=1, rate=1000)
|
||||||
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
|
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
|
||||||
@@ -532,6 +532,8 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
si.submit()
|
si.submit()
|
||||||
|
|
||||||
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
|
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
|
||||||
|
pe.source_exchange_rate = 50
|
||||||
|
pe.set_amounts()
|
||||||
pe.reference_no = si.name
|
pe.reference_no = si.name
|
||||||
pe.reference_date = nowdate()
|
pe.reference_date = nowdate()
|
||||||
|
|
||||||
@@ -607,6 +609,8 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
pe = get_payment_entry(
|
pe = get_payment_entry(
|
||||||
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
|
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
|
||||||
)
|
)
|
||||||
|
pe.source_exchange_rate = 50
|
||||||
|
pe.set_amounts()
|
||||||
pe.reference_no = "1"
|
pe.reference_no = "1"
|
||||||
pe.reference_date = "2016-01-01"
|
pe.reference_date = "2016-01-01"
|
||||||
|
|
||||||
@@ -1033,14 +1037,17 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
gle.credit_in_account_currency,
|
gle.credit_in_account_currency,
|
||||||
gle.debit_in_transaction_currency,
|
gle.debit_in_transaction_currency,
|
||||||
gle.credit_in_transaction_currency,
|
gle.credit_in_transaction_currency,
|
||||||
|
gle.transaction_currency,
|
||||||
|
gle.transaction_exchange_rate,
|
||||||
)
|
)
|
||||||
.orderby(gle.account)
|
.orderby(gle.account)
|
||||||
.where(gle.voucher_no == payment_entry.name)
|
.where(gle.voucher_no == payment_entry.name)
|
||||||
.run()
|
.run()
|
||||||
)
|
)
|
||||||
|
# transaction currency/rate come from the paid-from USD account (company currency is INR)
|
||||||
expected_gl_entries = (
|
expected_gl_entries = (
|
||||||
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0),
|
(paid_from, 0.0, 8440.0, 0.0, 100.0, 0.0, 100.0, "USD", 84.4),
|
||||||
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0),
|
("_Test Payable USD - _TC", 8440.0, 0.0, 100.0, 0.0, 100.0, 0.0, "USD", 84.4),
|
||||||
)
|
)
|
||||||
self.assertEqual(gl_entries, expected_gl_entries)
|
self.assertEqual(gl_entries, expected_gl_entries)
|
||||||
|
|
||||||
@@ -1106,6 +1113,27 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
|
|
||||||
self.assertEqual(gl_entries, expected_gl_entries)
|
self.assertEqual(gl_entries, expected_gl_entries)
|
||||||
|
|
||||||
|
def test_payment_entry_with_inclusive_tax(self):
|
||||||
|
# inclusive tax built server-side: base_tax_amount is None until apply_taxes()
|
||||||
|
payment_entry = create_payment_entry(paid_amount=1180)
|
||||||
|
payment_entry.append(
|
||||||
|
"taxes",
|
||||||
|
{
|
||||||
|
"account_head": "_Test Account Service Tax - _TC",
|
||||||
|
"charge_type": "On Paid Amount",
|
||||||
|
"rate": 18,
|
||||||
|
"included_in_paid_amount": 1,
|
||||||
|
"add_deduct_tax": "Add",
|
||||||
|
"description": "Service Tax",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payment_entry.save()
|
||||||
|
payment_entry.submit()
|
||||||
|
|
||||||
|
# 1180 incl 18% => 1000 base + 180 tax
|
||||||
|
self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0)
|
||||||
|
self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0)
|
||||||
|
|
||||||
def test_payment_entry_against_onhold_purchase_invoice(self):
|
def test_payment_entry_against_onhold_purchase_invoice(self):
|
||||||
pi = make_purchase_invoice()
|
pi = make_purchase_invoice()
|
||||||
|
|
||||||
@@ -1119,7 +1147,7 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
with self.assertRaises(frappe.ValidationError) as err:
|
with self.assertRaises(frappe.ValidationError) as err:
|
||||||
pe.save()
|
pe.save()
|
||||||
|
|
||||||
self.assertTrue("is on hold" in str(err.exception).lower())
|
self.assertIn("is on hold", str(err.exception).lower())
|
||||||
|
|
||||||
def test_payment_entry_for_employee(self):
|
def test_payment_entry_for_employee(self):
|
||||||
employee = make_employee("test_payment_entry@salary.com", company="_Test Company")
|
employee = make_employee("test_payment_entry@salary.com", company="_Test Company")
|
||||||
@@ -1567,7 +1595,7 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
self.check_pl_entries()
|
self.check_pl_entries()
|
||||||
|
|
||||||
def test_advance_as_liability_against_order(self):
|
def test_advance_as_liability_against_order(self):
|
||||||
from erpnext.buying.doctype.purchase_order.purchase_order import (
|
from erpnext.buying.doctype.purchase_order.mapper import (
|
||||||
make_purchase_invoice as _make_purchase_invoice,
|
make_purchase_invoice as _make_purchase_invoice,
|
||||||
)
|
)
|
||||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||||
@@ -2035,8 +2063,8 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
|
|
||||||
# check cancellation of payment entry and journal entry
|
# check cancellation of payment entry and journal entry
|
||||||
pe.cancel()
|
pe.cancel()
|
||||||
self.assertTrue(pe.docstatus == 2)
|
self.assertEqual(pe.docstatus, 2)
|
||||||
self.assertTrue(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus") == 2)
|
self.assertEqual(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus"), 2)
|
||||||
|
|
||||||
# check deletion of payment entry and journal entry
|
# check deletion of payment entry and journal entry
|
||||||
pe.delete()
|
pe.delete()
|
||||||
|
|||||||
@@ -10,76 +10,22 @@ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_ent
|
|||||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||||
from erpnext.stock.doctype.item.test_item import create_item
|
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
class TestPaymentLedgerEntry(ERPNextTestSuite):
|
class TestPaymentLedgerEntry(ERPNextTestSuite):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.ple = qb.DocType("Payment Ledger Entry")
|
self.ple = qb.DocType("Payment Ledger Entry")
|
||||||
self.create_company()
|
self.company = "_Test Company"
|
||||||
self.create_item()
|
self.cost_center = "Main - _TC"
|
||||||
self.create_customer()
|
self.warehouse = "Stores - _TC"
|
||||||
self.clear_old_entries()
|
self.income_account = "Sales - _TC"
|
||||||
|
self.expense_account = "Cost of Goods Sold - _TC"
|
||||||
def create_company(self):
|
self.debit_to = "Debtors - _TC"
|
||||||
company_name = "_Test Payment Ledger"
|
self.creditors = "Creditors - _TC"
|
||||||
company = None
|
self.bank = "Cash - _TC"
|
||||||
if frappe.db.exists("Company", company_name):
|
self.item = "_Test Item"
|
||||||
company = frappe.get_doc("Company", company_name)
|
self.customer = "_Test Customer"
|
||||||
else:
|
|
||||||
company = frappe.get_doc(
|
|
||||||
{
|
|
||||||
"doctype": "Company",
|
|
||||||
"company_name": company_name,
|
|
||||||
"country": "India",
|
|
||||||
"default_currency": "INR",
|
|
||||||
"create_chart_of_accounts_based_on": "Standard Template",
|
|
||||||
"chart_of_accounts": "Standard",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
company = company.save()
|
|
||||||
|
|
||||||
self.company = company.name
|
|
||||||
self.cost_center = company.cost_center
|
|
||||||
self.warehouse = "All Warehouses - _PL"
|
|
||||||
self.income_account = "Sales - _PL"
|
|
||||||
self.expense_account = "Cost of Goods Sold - _PL"
|
|
||||||
self.debit_to = "Debtors - _PL"
|
|
||||||
self.creditors = "Creditors - _PL"
|
|
||||||
|
|
||||||
# create bank account
|
|
||||||
if frappe.db.exists("Account", "HDFC - _PL"):
|
|
||||||
self.bank = "HDFC - _PL"
|
|
||||||
else:
|
|
||||||
bank_acc = frappe.get_doc(
|
|
||||||
{
|
|
||||||
"doctype": "Account",
|
|
||||||
"account_name": "HDFC",
|
|
||||||
"parent_account": "Bank Accounts - _PL",
|
|
||||||
"company": self.company,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
bank_acc.save()
|
|
||||||
self.bank = bank_acc.name
|
|
||||||
|
|
||||||
def create_item(self):
|
|
||||||
item_name = "_Test PL Item"
|
|
||||||
item = create_item(
|
|
||||||
item_code=item_name, is_stock_item=0, company=self.company, warehouse=self.warehouse
|
|
||||||
)
|
|
||||||
self.item = item if isinstance(item, str) else item.item_code
|
|
||||||
|
|
||||||
def create_customer(self):
|
|
||||||
name = "_Test PL Customer"
|
|
||||||
if frappe.db.exists("Customer", name):
|
|
||||||
self.customer = name
|
|
||||||
else:
|
|
||||||
customer = frappe.new_doc("Customer")
|
|
||||||
customer.customer_name = name
|
|
||||||
customer.type = "Individual"
|
|
||||||
customer.save()
|
|
||||||
self.customer = customer.name
|
|
||||||
|
|
||||||
def create_sales_invoice(
|
def create_sales_invoice(
|
||||||
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
self, qty=1, rate=100, posting_date=None, do_not_save=False, do_not_submit=False
|
||||||
@@ -152,18 +98,6 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
|||||||
)
|
)
|
||||||
return so
|
return so
|
||||||
|
|
||||||
def clear_old_entries(self):
|
|
||||||
doctype_list = [
|
|
||||||
"GL Entry",
|
|
||||||
"Payment Ledger Entry",
|
|
||||||
"Sales Invoice",
|
|
||||||
"Purchase Invoice",
|
|
||||||
"Payment Entry",
|
|
||||||
"Journal Entry",
|
|
||||||
]
|
|
||||||
for doctype in doctype_list:
|
|
||||||
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
|
|
||||||
|
|
||||||
def create_journal_entry(self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None):
|
def create_journal_entry(self, acc1=None, acc2=None, amount=0, posting_date=None, cost_center=None):
|
||||||
je = frappe.new_doc("Journal Entry")
|
je = frappe.new_doc("Journal Entry")
|
||||||
je.posting_date = posting_date or nowdate()
|
je.posting_date = posting_date or nowdate()
|
||||||
|
|||||||
@@ -60,23 +60,32 @@ class PaymentOrder(Document):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||||
return frappe.db.sql(
|
return frappe.get_all(
|
||||||
""" select mode_of_payment from `tabPayment Order Reference`
|
"Payment Order Reference",
|
||||||
where parent = %(parent)s and mode_of_payment like %(txt)s
|
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
|
||||||
limit %(page_len)s offset %(start)s""",
|
fields=["mode_of_payment"],
|
||||||
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
|
limit_start=start,
|
||||||
|
limit_page_length=page_len,
|
||||||
|
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||||
|
as_list=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||||
return frappe.db.sql(
|
return frappe.get_all(
|
||||||
""" select supplier from `tabPayment Order Reference`
|
"Payment Order Reference",
|
||||||
where parent = %(parent)s and supplier like %(txt)s and
|
filters={
|
||||||
(payment_reference is null or payment_reference='')
|
"parent": filters.get("parent"),
|
||||||
limit %(page_len)s offset %(start)s""",
|
"supplier": ["like", f"%{txt}%"],
|
||||||
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
|
"payment_reference": ["is", "not set"],
|
||||||
|
},
|
||||||
|
fields=["supplier"],
|
||||||
|
limit_start=start,
|
||||||
|
limit_page_length=page_len,
|
||||||
|
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||||
|
as_list=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
|
|||||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||||
is_any_doc_running,
|
is_any_doc_running,
|
||||||
)
|
)
|
||||||
|
from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
|
||||||
from erpnext.accounts.utils import (
|
from erpnext.accounts.utils import (
|
||||||
QueryPaymentLedger,
|
QueryPaymentLedger,
|
||||||
create_gain_loss_journal,
|
create_gain_loss_journal,
|
||||||
get_outstanding_invoices,
|
get_outstanding_invoices,
|
||||||
reconcile_against_document,
|
reconcile_against_document,
|
||||||
)
|
)
|
||||||
from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentReconciliation(Document):
|
class PaymentReconciliation(Document):
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import qb
|
|
||||||
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
||||||
from frappe.utils.data import getdate as convert_to_date
|
from frappe.utils.data import getdate as convert_to_date
|
||||||
|
|
||||||
from erpnext import get_default_cost_center
|
|
||||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||||
@@ -15,7 +13,6 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
|
|||||||
from erpnext.accounts.party import get_party_account
|
from erpnext.accounts.party import get_party_account
|
||||||
from erpnext.accounts.utils import get_fiscal_year
|
from erpnext.accounts.utils import get_fiscal_year
|
||||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||||
from erpnext.stock.doctype.item.test_item import create_item
|
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ from erpnext import get_company_currency
|
|||||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||||
get_accounting_dimensions,
|
get_accounting_dimensions,
|
||||||
)
|
)
|
||||||
|
from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account
|
||||||
from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
||||||
get_payment_entry,
|
get_payment_entry,
|
||||||
)
|
)
|
||||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||||
from erpnext.accounts.party import get_party_account, get_party_bank_account
|
from erpnext.accounts.party import get_party_account
|
||||||
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision
|
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision
|
||||||
from erpnext.utilities import payment_app_import_guard
|
from erpnext.utilities import payment_app_import_guard
|
||||||
|
|
||||||
@@ -443,7 +444,7 @@ class PaymentRequest(Document):
|
|||||||
self.update_reference_advance_payment_status()
|
self.update_reference_advance_payment_status()
|
||||||
|
|
||||||
def make_invoice(self):
|
def make_invoice(self):
|
||||||
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
|
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
|
||||||
|
|
||||||
si = make_sales_invoice(self.reference_name, ignore_permissions=True)
|
si = make_sales_invoice(self.reference_name, ignore_permissions=True)
|
||||||
si.allocate_advances_automatically = True
|
si.allocate_advances_automatically = True
|
||||||
@@ -628,11 +629,9 @@ class PaymentRequest(Document):
|
|||||||
|
|
||||||
def check_if_payment_entry_exists(self):
|
def check_if_payment_entry_exists(self):
|
||||||
if self.status == "Paid":
|
if self.status == "Paid":
|
||||||
if frappe.get_all(
|
if frappe.db.exists(
|
||||||
"Payment Entry Reference",
|
"Payment Entry Reference",
|
||||||
filters={"reference_name": self.reference_name, "docstatus": ["<", 2]},
|
{"reference_name": self.reference_name, "docstatus": ["<", 2]},
|
||||||
fields=["parent"],
|
|
||||||
limit=1,
|
|
||||||
):
|
):
|
||||||
frappe.throw(_("Payment Entry already exists"), title=_("Error"))
|
frappe.throw(_("Payment Entry already exists"), title=_("Error"))
|
||||||
|
|
||||||
@@ -1211,10 +1210,11 @@ def get_dummy_message(doc):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_subscription_details(reference_doctype: str, reference_name: str):
|
def get_subscription_details(reference_doctype: str, reference_name: str):
|
||||||
if reference_doctype == "Sales Invoice":
|
if reference_doctype == "Sales Invoice":
|
||||||
subscriptions = frappe.db.sql(
|
subscriptions = frappe.get_all(
|
||||||
"""SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""",
|
"Subscription Invoice",
|
||||||
reference_name,
|
filters={"invoice": reference_name},
|
||||||
as_dict=1,
|
fields=["parent as sub_name"],
|
||||||
|
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
|
||||||
)
|
)
|
||||||
subscription_plans = []
|
subscription_plans = []
|
||||||
for subscription in subscriptions:
|
for subscription in subscriptions:
|
||||||
|
|||||||
@@ -332,7 +332,12 @@ class TestPaymentRequest(ERPNextTestSuite):
|
|||||||
return_doc=1,
|
return_doc=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
pe = pr.set_as_paid()
|
pe = pr.create_payment_entry(submit=False)
|
||||||
|
pe.source_exchange_rate = 50
|
||||||
|
pe.target_exchange_rate = 50
|
||||||
|
pe.set_amounts()
|
||||||
|
pe.insert(ignore_permissions=True)
|
||||||
|
pe.submit()
|
||||||
|
|
||||||
expected_gle = dict(
|
expected_gle = dict(
|
||||||
(d[0], d)
|
(d[0], d)
|
||||||
@@ -418,7 +423,12 @@ class TestPaymentRequest(ERPNextTestSuite):
|
|||||||
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
|
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
|
||||||
pr = frappe.get_doc(pr).save().submit()
|
pr = frappe.get_doc(pr).save().submit()
|
||||||
|
|
||||||
pe = pr.create_payment_entry()
|
pe = pr.create_payment_entry(submit=False)
|
||||||
|
pe.target_exchange_rate = 80
|
||||||
|
pe.paid_amount = 800
|
||||||
|
pe.set_amounts()
|
||||||
|
pe.insert(ignore_permissions=True)
|
||||||
|
pe.submit()
|
||||||
self.assertEqual(pe.base_paid_amount, 800)
|
self.assertEqual(pe.base_paid_amount, 800)
|
||||||
self.assertEqual(pe.paid_amount, 800)
|
self.assertEqual(pe.paid_amount, 800)
|
||||||
self.assertEqual(pe.base_received_amount, 800)
|
self.assertEqual(pe.base_received_amount, 800)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
from frappe.utils import add_days, getdate
|
||||||
|
|
||||||
|
from erpnext.controllers.accounts_controller import get_payment_term_details
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +57,52 @@ class TestPaymentTermsTemplate(ERPNextTestSuite):
|
|||||||
|
|
||||||
self.assertRaises(frappe.ValidationError, template.insert)
|
self.assertRaises(frappe.ValidationError, template.insert)
|
||||||
|
|
||||||
|
def test_no_discount_date_without_discount(self):
|
||||||
|
posting_date = "2026-05-29"
|
||||||
|
term = frappe._dict(
|
||||||
|
{
|
||||||
|
"payment_term": "_Test No Discount Term",
|
||||||
|
"invoice_portion": 100.0,
|
||||||
|
"due_date_based_on": "Day(s) after invoice date",
|
||||||
|
"credit_days": 0,
|
||||||
|
"credit_months": 0,
|
||||||
|
"discount_type": "Percentage",
|
||||||
|
"discount": 0,
|
||||||
|
"discount_validity_based_on": "Day(s) after invoice date",
|
||||||
|
"discount_validity": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
details = get_payment_term_details(
|
||||||
|
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(getdate(details.due_date), getdate(posting_date))
|
||||||
|
self.assertIsNone(details.discount_date)
|
||||||
|
|
||||||
|
def test_discount_date_generated_with_discount(self):
|
||||||
|
posting_date = "2026-05-29"
|
||||||
|
term = frappe._dict(
|
||||||
|
{
|
||||||
|
"payment_term": "_Test Discount Term",
|
||||||
|
"invoice_portion": 100.0,
|
||||||
|
"due_date_based_on": "Day(s) after invoice date",
|
||||||
|
"credit_days": 30,
|
||||||
|
"credit_months": 0,
|
||||||
|
"discount_type": "Percentage",
|
||||||
|
"discount": 5,
|
||||||
|
"discount_validity_based_on": "Day(s) after invoice date",
|
||||||
|
"discount_validity": 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
details = get_payment_term_details(
|
||||||
|
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(getdate(details.due_date), getdate(add_days(posting_date, 30)))
|
||||||
|
self.assertEqual(getdate(details.discount_date), getdate(add_days(posting_date, 10)))
|
||||||
|
|
||||||
def test_duplicate_terms(self):
|
def test_duplicate_terms(self):
|
||||||
template = frappe.get_doc(
|
template = frappe.get_doc(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ class PeriodClosingVoucher(AccountsController):
|
|||||||
if not previous_fiscal_year:
|
if not previous_fiscal_year:
|
||||||
return
|
return
|
||||||
|
|
||||||
previous_fiscal_year_start_date = previous_fiscal_year[0][1]
|
# get_fiscal_year() returns a single (name, start_date, end_date) tuple, so the start date
|
||||||
|
# is [1]; the old [0][1] read the 2nd char of the name ('T'), which MariaDB silently
|
||||||
|
# coerced to NULL but postgres rejects as an invalid date.
|
||||||
|
previous_fiscal_year_start_date = previous_fiscal_year[1]
|
||||||
previous_fiscal_year_closed = frappe.db.exists(
|
previous_fiscal_year_closed = frappe.db.exists(
|
||||||
"Period Closing Voucher",
|
"Period Closing Voucher",
|
||||||
{
|
{
|
||||||
@@ -287,41 +290,44 @@ class PeriodClosingVoucher(AccountsController):
|
|||||||
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
|
self.accounting_dimension_fields = default_dimensions + get_accounting_dimensions()
|
||||||
|
|
||||||
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
|
def get_gl_entries_for_current_period(self, report_type, only_opening_entries=False, as_iterator=False):
|
||||||
date_condition = ""
|
gle = frappe.qb.DocType("GL Entry")
|
||||||
if only_opening_entries:
|
account = frappe.qb.DocType("Account")
|
||||||
date_condition = "is_opening = 'Yes'"
|
|
||||||
else:
|
|
||||||
date_condition = f"posting_date BETWEEN '{self.period_start_date}' AND '{self.period_end_date}' and is_opening = 'No'"
|
|
||||||
|
|
||||||
# nosemgrep
|
fields = [
|
||||||
return frappe.db.sql(
|
gle.name,
|
||||||
"""
|
gle.posting_date,
|
||||||
SELECT
|
gle.account,
|
||||||
name,
|
gle.account_currency,
|
||||||
posting_date,
|
gle.debit_in_account_currency,
|
||||||
account,
|
gle.credit_in_account_currency,
|
||||||
account_currency,
|
gle.debit,
|
||||||
debit_in_account_currency,
|
gle.credit,
|
||||||
credit_in_account_currency,
|
]
|
||||||
debit,
|
fields += [gle[dimension] for dimension in self.accounting_dimension_fields]
|
||||||
credit,
|
|
||||||
{}
|
query = (
|
||||||
FROM `tabGL Entry`
|
frappe.qb.from_(gle)
|
||||||
WHERE
|
.select(*fields)
|
||||||
{}
|
.where(
|
||||||
AND company = %s
|
(gle.company == self.company)
|
||||||
AND voucher_type != 'Period Closing Voucher'
|
& (gle.voucher_type != "Period Closing Voucher")
|
||||||
AND EXISTS(SELECT name FROM `tabAccount` WHERE name = account AND report_type = %s)
|
& (gle.is_cancelled == 0)
|
||||||
AND is_cancelled = 0
|
& gle.account.isin(
|
||||||
""".format(
|
frappe.qb.from_(account).select(account.name).where(account.report_type == report_type)
|
||||||
", ".join(self.accounting_dimension_fields),
|
)
|
||||||
date_condition,
|
)
|
||||||
),
|
|
||||||
(self.company, report_type),
|
|
||||||
as_dict=1,
|
|
||||||
as_iterator=as_iterator,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if only_opening_entries:
|
||||||
|
query = query.where(gle.is_opening == "Yes")
|
||||||
|
else:
|
||||||
|
query = query.where(
|
||||||
|
gle.posting_date.between(self.period_start_date, self.period_end_date)
|
||||||
|
& (gle.is_opening == "No")
|
||||||
|
)
|
||||||
|
|
||||||
|
return query.run(as_dict=1, as_iterator=as_iterator)
|
||||||
|
|
||||||
def set_account_balance_dict(self, gle, acc_bal_dict):
|
def set_account_balance_dict(self, gle, acc_bal_dict):
|
||||||
key = self.get_key(gle)
|
key = self.get_key(gle)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
||||||
|
|
||||||
def test_closing_entry(self):
|
def test_closing_entry(self):
|
||||||
company = create_company()
|
|
||||||
cost_center = create_cost_center("Test Cost Center 1")
|
cost_center = create_cost_center("Test Cost Center 1")
|
||||||
|
|
||||||
jv1 = make_journal_entry(
|
jv1 = make_journal_entry(
|
||||||
@@ -27,10 +26,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
account1="Cash - TPC",
|
account1="Cash - TPC",
|
||||||
account2="Sales - TPC",
|
account2="Sales - TPC",
|
||||||
cost_center=cost_center,
|
cost_center=cost_center,
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
save=False,
|
save=False,
|
||||||
)
|
)
|
||||||
jv1.company = company
|
jv1.company = "Test PCV Company"
|
||||||
jv1.save()
|
jv1.save()
|
||||||
jv1.submit()
|
jv1.submit()
|
||||||
|
|
||||||
@@ -40,10 +39,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
account1="Cost of Goods Sold - TPC",
|
account1="Cost of Goods Sold - TPC",
|
||||||
account2="Cash - TPC",
|
account2="Cash - TPC",
|
||||||
cost_center=cost_center,
|
cost_center=cost_center,
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
save=False,
|
save=False,
|
||||||
)
|
)
|
||||||
jv2.company = company
|
jv2.company = "Test PCV Company"
|
||||||
jv2.save()
|
jv2.save()
|
||||||
jv2.submit()
|
jv2.submit()
|
||||||
|
|
||||||
@@ -56,25 +55,28 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
("Sales - TPC", 400.0, 0.0),
|
("Sales - TPC", 400.0, 0.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
pcv_gle = frappe.db.sql(
|
pcv_gle = [
|
||||||
"""
|
tuple(row)
|
||||||
select account, debit, credit from `tabGL Entry` where voucher_no=%s order by account
|
for row in frappe.get_all(
|
||||||
""",
|
"GL Entry",
|
||||||
(pcv.name),
|
filters={"voucher_no": pcv.name},
|
||||||
)
|
fields=["account", "debit", "credit"],
|
||||||
|
order_by="account",
|
||||||
|
as_list=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
pcv.reload()
|
pcv.reload()
|
||||||
self.assertEqual(pcv.gle_processing_status, "Completed")
|
self.assertEqual(pcv.gle_processing_status, "Completed")
|
||||||
self.assertEqual(pcv_gle, expected_gle)
|
self.assertEqual(tuple(pcv_gle), expected_gle)
|
||||||
|
|
||||||
def test_cost_center_wise_posting(self):
|
def test_cost_center_wise_posting(self):
|
||||||
company = create_company()
|
|
||||||
surplus_account = create_account()
|
surplus_account = create_account()
|
||||||
|
|
||||||
cost_center1 = create_cost_center("Main")
|
cost_center1 = create_cost_center("Main")
|
||||||
cost_center2 = create_cost_center("Western Branch")
|
cost_center2 = create_cost_center("Western Branch")
|
||||||
|
|
||||||
create_sales_invoice(
|
create_sales_invoice(
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
cost_center=cost_center1,
|
cost_center=cost_center1,
|
||||||
income_account="Sales - TPC",
|
income_account="Sales - TPC",
|
||||||
expense_account="Cost of Goods Sold - TPC",
|
expense_account="Cost of Goods Sold - TPC",
|
||||||
@@ -85,7 +87,7 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
posting_date="2021-03-15",
|
posting_date="2021-03-15",
|
||||||
)
|
)
|
||||||
create_sales_invoice(
|
create_sales_invoice(
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
cost_center=cost_center2,
|
cost_center=cost_center2,
|
||||||
income_account="Sales - TPC",
|
income_account="Sales - TPC",
|
||||||
expense_account="Cost of Goods Sold - TPC",
|
expense_account="Cost of Goods Sold - TPC",
|
||||||
@@ -108,14 +110,16 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
("Sales - TPC", 200.0, 0.0, cost_center2),
|
("Sales - TPC", 200.0, 0.0, cost_center2),
|
||||||
)
|
)
|
||||||
|
|
||||||
pcv_gle = frappe.db.sql(
|
pcv_gle = [
|
||||||
"""
|
tuple(row)
|
||||||
select account, debit, credit, cost_center
|
for row in frappe.get_all(
|
||||||
from `tabGL Entry` where voucher_no=%s
|
"GL Entry",
|
||||||
order by account, cost_center
|
filters={"voucher_no": pcv.name},
|
||||||
""",
|
fields=["account", "debit", "credit", "cost_center"],
|
||||||
(pcv.name),
|
order_by="account, cost_center",
|
||||||
)
|
as_list=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
self.assertSequenceEqual(pcv_gle, expected_gle)
|
||||||
|
|
||||||
@@ -130,12 +134,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_period_closing_with_finance_book_entries(self):
|
def test_period_closing_with_finance_book_entries(self):
|
||||||
company = create_company()
|
|
||||||
surplus_account = create_account()
|
surplus_account = create_account()
|
||||||
cost_center = create_cost_center("Test Cost Center 1")
|
cost_center = create_cost_center("Test Cost Center 1")
|
||||||
|
|
||||||
create_sales_invoice(
|
create_sales_invoice(
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
income_account="Sales - TPC",
|
income_account="Sales - TPC",
|
||||||
expense_account="Cost of Goods Sold - TPC",
|
expense_account="Cost of Goods Sold - TPC",
|
||||||
cost_center=cost_center,
|
cost_center=cost_center,
|
||||||
@@ -152,9 +155,9 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
amount=400,
|
amount=400,
|
||||||
cost_center=cost_center,
|
cost_center=cost_center,
|
||||||
posting_date="2021-03-15",
|
posting_date="2021-03-15",
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
)
|
)
|
||||||
jv.company = company
|
jv.company = "Test PCV Company"
|
||||||
jv.finance_book = create_finance_book().name
|
jv.finance_book = create_finance_book().name
|
||||||
jv.save()
|
jv.save()
|
||||||
jv.submit()
|
jv.submit()
|
||||||
@@ -169,19 +172,21 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
("Sales - TPC", 400.0, 0.0, jv.finance_book),
|
("Sales - TPC", 400.0, 0.0, jv.finance_book),
|
||||||
)
|
)
|
||||||
|
|
||||||
pcv_gle = frappe.db.sql(
|
pcv_gle = [
|
||||||
"""
|
tuple(row)
|
||||||
select account, debit, credit, finance_book
|
for row in frappe.get_all(
|
||||||
from `tabGL Entry` where voucher_no=%s
|
"GL Entry",
|
||||||
order by account, finance_book
|
filters={"voucher_no": pcv.name},
|
||||||
""",
|
fields=["account", "debit", "credit", "finance_book"],
|
||||||
(pcv.name),
|
order_by="account, finance_book",
|
||||||
)
|
as_list=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
self.assertSequenceEqual(pcv_gle, expected_gle)
|
# compare order-independently: postgres and MariaDB order NULL finance_book differently
|
||||||
|
self.assertSequenceEqual(sorted(pcv_gle, key=str), sorted(expected_gle, key=str))
|
||||||
|
|
||||||
def test_gl_entries_restrictions(self):
|
def test_gl_entries_restrictions(self):
|
||||||
company = create_company()
|
|
||||||
cost_center = create_cost_center("Test Cost Center 1")
|
cost_center = create_cost_center("Test Cost Center 1")
|
||||||
|
|
||||||
self.make_period_closing_voucher(posting_date="2021-03-31")
|
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||||
@@ -192,16 +197,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
account1="Cash - TPC",
|
account1="Cash - TPC",
|
||||||
account2="Sales - TPC",
|
account2="Sales - TPC",
|
||||||
cost_center=cost_center,
|
cost_center=cost_center,
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
save=False,
|
save=False,
|
||||||
)
|
)
|
||||||
jv1.company = company
|
jv1.company = "Test PCV Company"
|
||||||
jv1.save()
|
jv1.save()
|
||||||
|
|
||||||
self.assertRaises(frappe.ValidationError, jv1.submit)
|
self.assertRaises(frappe.ValidationError, jv1.submit)
|
||||||
|
|
||||||
def test_closing_balance_with_dimensions_and_test_reposting_entry(self):
|
def test_closing_balance_with_dimensions_and_test_reposting_entry(self):
|
||||||
company = create_company()
|
|
||||||
cost_center1 = create_cost_center("Test Cost Center 1")
|
cost_center1 = create_cost_center("Test Cost Center 1")
|
||||||
cost_center2 = create_cost_center("Test Cost Center 2")
|
cost_center2 = create_cost_center("Test Cost Center 2")
|
||||||
|
|
||||||
@@ -211,10 +215,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
account1="Cash - TPC",
|
account1="Cash - TPC",
|
||||||
account2="Sales - TPC",
|
account2="Sales - TPC",
|
||||||
cost_center=cost_center1,
|
cost_center=cost_center1,
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
save=False,
|
save=False,
|
||||||
)
|
)
|
||||||
jv1.company = company
|
jv1.company = "Test PCV Company"
|
||||||
jv1.save()
|
jv1.save()
|
||||||
jv1.submit()
|
jv1.submit()
|
||||||
|
|
||||||
@@ -224,10 +228,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
account1="Cash - TPC",
|
account1="Cash - TPC",
|
||||||
account2="Sales - TPC",
|
account2="Sales - TPC",
|
||||||
cost_center=cost_center2,
|
cost_center=cost_center2,
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
save=False,
|
save=False,
|
||||||
)
|
)
|
||||||
jv2.company = company
|
jv2.company = "Test PCV Company"
|
||||||
jv2.save()
|
jv2.save()
|
||||||
jv2.submit()
|
jv2.submit()
|
||||||
|
|
||||||
@@ -254,11 +258,11 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
account1="Cash - TPC",
|
account1="Cash - TPC",
|
||||||
account2="Sales - TPC",
|
account2="Sales - TPC",
|
||||||
cost_center=cost_center2,
|
cost_center=cost_center2,
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
save=False,
|
save=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
jv3.company = company
|
jv3.company = "Test PCV Company"
|
||||||
jv3.save()
|
jv3.save()
|
||||||
jv3.submit()
|
jv3.submit()
|
||||||
|
|
||||||
@@ -293,12 +297,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
self.assertEqual(cc2_closing_balance.credit, 500)
|
self.assertEqual(cc2_closing_balance.credit, 500)
|
||||||
self.assertEqual(cc2_closing_balance.credit_in_account_currency, 500)
|
self.assertEqual(cc2_closing_balance.credit_in_account_currency, 500)
|
||||||
|
|
||||||
warehouse = frappe.db.get_value("Warehouse", {"company": company}, "name")
|
warehouse = frappe.db.get_value("Warehouse", {"company": "Test PCV Company"}, "name")
|
||||||
|
|
||||||
repost_doc = frappe.get_doc(
|
repost_doc = frappe.get_doc(
|
||||||
{
|
{
|
||||||
"doctype": "Repost Item Valuation",
|
"doctype": "Repost Item Valuation",
|
||||||
"company": company,
|
"company": "Test PCV Company",
|
||||||
"posting_date": "2020-03-15",
|
"posting_date": "2020-03-15",
|
||||||
"based_on": "Item and Warehouse",
|
"based_on": "Item and Warehouse",
|
||||||
"item_code": "Test Item 1",
|
"item_code": "Test Item 1",
|
||||||
@@ -339,7 +343,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
{"enable_immutable_ledger": 1},
|
{"enable_immutable_ledger": 1},
|
||||||
)
|
)
|
||||||
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
|
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
|
||||||
company = create_company()
|
|
||||||
cost_center = create_cost_center("Test Cost Center 1")
|
cost_center = create_cost_center("Test Cost Center 1")
|
||||||
|
|
||||||
jv = make_journal_entry(
|
jv = make_journal_entry(
|
||||||
@@ -348,10 +351,10 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
account1="Cash - TPC",
|
account1="Cash - TPC",
|
||||||
account2="Sales - TPC",
|
account2="Sales - TPC",
|
||||||
cost_center=cost_center,
|
cost_center=cost_center,
|
||||||
company=company,
|
company="Test PCV Company",
|
||||||
save=False,
|
save=False,
|
||||||
)
|
)
|
||||||
jv.company = company
|
jv.company = "Test PCV Company"
|
||||||
jv.save()
|
jv.save()
|
||||||
jv.submit()
|
jv.submit()
|
||||||
|
|
||||||
@@ -364,32 +367,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
posting_date="2022-01-01",
|
posting_date="2022-01-01",
|
||||||
)
|
)
|
||||||
|
|
||||||
totals_after_cancel = frappe.db.sql(
|
totals_after_cancel = frappe.get_all(
|
||||||
"""
|
"GL Entry",
|
||||||
select sum(debit) as total_debit, sum(credit) as total_credit
|
filters={"voucher_type": "Journal Entry", "voucher_no": jv.name, "is_cancelled": 0},
|
||||||
from `tabGL Entry`
|
fields=[{"SUM": "debit", "as": "total_debit"}, {"SUM": "credit", "as": "total_credit"}],
|
||||||
where voucher_type=%s and voucher_no=%s and is_cancelled=0
|
|
||||||
""",
|
|
||||||
("Journal Entry", jv.name),
|
|
||||||
as_dict=True,
|
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||||
|
|
||||||
|
|
||||||
def create_company():
|
|
||||||
company = frappe.get_doc(
|
|
||||||
{
|
|
||||||
"doctype": "Company",
|
|
||||||
"company_name": "Test PCV Company",
|
|
||||||
"country": "United States",
|
|
||||||
"default_currency": "USD",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
company.insert(ignore_if_duplicate=True)
|
|
||||||
return company.name
|
|
||||||
|
|
||||||
|
|
||||||
def create_account():
|
def create_account():
|
||||||
account = frappe.get_doc(
|
account = frappe.get_doc(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ def get_payments(invoices):
|
|||||||
.groupby(SalesInvoicePayment.mode_of_payment)
|
.groupby(SalesInvoicePayment.mode_of_payment)
|
||||||
.select(
|
.select(
|
||||||
SalesInvoicePayment.mode_of_payment,
|
SalesInvoicePayment.mode_of_payment,
|
||||||
SalesInvoicePayment.account,
|
fn.Max(SalesInvoicePayment.account).as_("account"),
|
||||||
fn.Sum(SalesInvoicePayment.amount).as_("amount"),
|
fn.Sum(SalesInvoicePayment.amount).as_("amount"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -419,7 +419,7 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
|
|||||||
InvoiceDocType.account_for_change_amount,
|
InvoiceDocType.account_for_change_amount,
|
||||||
InvoiceDocType.is_return,
|
InvoiceDocType.is_return,
|
||||||
InvoiceDocType.return_against,
|
InvoiceDocType.return_against,
|
||||||
fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
|
fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time).as_("timestamp"),
|
||||||
ConstantColumn(invoice_doctype).as_("doctype"),
|
ConstantColumn(invoice_doctype).as_("doctype"),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
@@ -428,8 +428,8 @@ def build_invoice_query(invoice_doctype, user, pos_profile, start, end):
|
|||||||
& (InvoiceDocType.is_pos == 1)
|
& (InvoiceDocType.is_pos == 1)
|
||||||
& (InvoiceDocType.pos_profile == pos_profile)
|
& (InvoiceDocType.pos_profile == pos_profile)
|
||||||
& (
|
& (
|
||||||
(fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
|
(fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) >= start)
|
||||||
& (fn.Timestamp(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
|
& (fn.CombineDatetime(InvoiceDocType.posting_date, InvoiceDocType.posting_time) <= end)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class TestPOSClosingEntry(ERPNextTestSuite):
|
|||||||
"""
|
"""
|
||||||
Test Sales Invoice and Return Sales Invoice creation during POS Invoice mode.
|
Test Sales Invoice and Return Sales Invoice creation during POS Invoice mode.
|
||||||
"""
|
"""
|
||||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
|
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||||
|
|
||||||
test_user, pos_profile = init_user_and_profile()
|
test_user, pos_profile = init_user_and_profile()
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user