Compare commits
1210 Commits
main
...
feat/fb-le
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453dccb33f | ||
|
|
f85a8cc281 | ||
|
|
d62229b9be | ||
|
|
38e10f91cb | ||
|
|
1b87635d3d | ||
|
|
176c7c73bb | ||
|
|
91b8a48c0e | ||
|
|
45bc5925d1 | ||
|
|
eb0b189d4c | ||
|
|
49b2c66299 | ||
|
|
4e5c2f5426 | ||
|
|
595c1b0b11 | ||
|
|
b6f8cbd5eb | ||
|
|
872f271eb1 | ||
|
|
5fc0ca9bf0 | ||
|
|
19c69d1495 | ||
|
|
5feaddcd6f | ||
|
|
56de0ce0d9 | ||
|
|
24f800c8e2 | ||
|
|
094869d2e7 | ||
|
|
373102d182 | ||
|
|
d69b86bcaf | ||
|
|
4148640472 | ||
|
|
9237398342 | ||
|
|
788fe36cfa | ||
|
|
cd366839fc | ||
|
|
6e1dd04819 | ||
|
|
d125d12a36 | ||
|
|
6db4d94e8a | ||
|
|
b12c55531f | ||
|
|
ddabd82f21 | ||
|
|
f435d74d0a | ||
|
|
5e5929ef40 | ||
|
|
7d2ccc2e58 | ||
|
|
9cd86e99c3 | ||
|
|
3ac4a582f5 | ||
|
|
d0cccc2e61 | ||
|
|
b1b7c5d246 | ||
|
|
4b6d2fb9b6 | ||
|
|
171060df8a | ||
|
|
96b28cf11a | ||
|
|
dbcda4c548 | ||
|
|
9e92282e25 | ||
|
|
0138716e07 | ||
|
|
589c95e263 | ||
|
|
a4e2663b24 | ||
|
|
f91ac266ca | ||
|
|
415d5410ba | ||
|
|
9fc20a3078 | ||
|
|
43dadfe746 | ||
|
|
56071d5e0d | ||
|
|
b0e79d0aef | ||
|
|
5848649955 | ||
|
|
3c5ee979f8 | ||
|
|
2acd7476c8 | ||
|
|
66c5865828 | ||
|
|
103a137af1 | ||
|
|
52cc70d704 | ||
|
|
d72dcee7b6 | ||
|
|
b473b27f9a | ||
|
|
8805560144 | ||
|
|
3f0c4e9614 | ||
|
|
2b13c3f277 | ||
|
|
b6fa3bf32b | ||
|
|
ae9e59aa00 | ||
|
|
4533becc62 | ||
|
|
57bd9fe70a | ||
|
|
013c21a5d1 | ||
|
|
9a780039e5 | ||
|
|
1bd62289dc | ||
|
|
f7382f40ac | ||
|
|
3c870ce042 | ||
|
|
c05c2393f8 | ||
|
|
c16f2a286f | ||
|
|
07994ec071 | ||
|
|
96c0c99939 | ||
|
|
ce632c69c1 | ||
|
|
625e472303 | ||
|
|
93ed6fcddd | ||
|
|
e3eff7f78d | ||
|
|
394da5e002 | ||
|
|
887ae4ef57 | ||
|
|
536d657e5b | ||
|
|
d687a2eb56 | ||
|
|
1044adc494 | ||
|
|
9f95a3a2b2 | ||
|
|
fca831b92e | ||
|
|
69f8090311 | ||
|
|
ac34ac9b87 | ||
|
|
129f8a00b6 | ||
|
|
6328b6941b | ||
|
|
fbc9e37036 | ||
|
|
149901f605 | ||
|
|
7e21a5fee2 | ||
|
|
f4ff6bbdf3 | ||
|
|
9150233173 | ||
|
|
186584c1ac | ||
|
|
3752c61157 | ||
|
|
a6ecc5cfed | ||
|
|
84e0fe30a9 | ||
|
|
03acea69b1 | ||
|
|
e19f750831 | ||
|
|
a52bfee98d | ||
|
|
41ef219d0a | ||
|
|
0a2f9e31c0 | ||
|
|
69bcf0846c | ||
|
|
b6e3cdfc37 | ||
|
|
c0171c0555 | ||
|
|
6f154e191a | ||
|
|
552e500a31 | ||
|
|
bf6940a6ff | ||
|
|
dc9b07b02a | ||
|
|
0a45094c33 | ||
|
|
247d8e043e | ||
|
|
73a1ecd418 | ||
|
|
77e7bb011b | ||
|
|
9233e77ab8 | ||
|
|
32e5d56ef1 | ||
|
|
cea6b6c6b4 | ||
|
|
2d636d7ffb | ||
|
|
9d9caf2856 | ||
|
|
4ff4f3c5b5 | ||
|
|
8167e1388d | ||
|
|
8e3cf3846a | ||
|
|
2d8ada04c8 | ||
|
|
4fba2353cf | ||
|
|
f251d83e97 | ||
|
|
283b34662e | ||
|
|
9bcfcf4ac7 | ||
|
|
d80bbcd33d | ||
|
|
42ee5ea64d | ||
|
|
1472a7f33d | ||
|
|
aabcb9b7ce | ||
|
|
9e8a247dee | ||
|
|
2e5f4a9d22 | ||
|
|
100d01334a | ||
|
|
03ab96d94f | ||
|
|
6f640f5eee | ||
|
|
1627cf1e54 | ||
|
|
376917bc75 | ||
|
|
54753d3274 | ||
|
|
3c3108a9c1 | ||
|
|
e0cfae1eb3 | ||
|
|
fa03245eff | ||
|
|
f253392ba7 | ||
|
|
6e608d845b | ||
|
|
db577afc56 | ||
|
|
3b32b9a766 | ||
|
|
ffd2452675 | ||
|
|
b2949afd33 | ||
|
|
097c58b991 | ||
|
|
64bf702b62 | ||
|
|
7e27e9f45e | ||
|
|
ee19b344ec | ||
|
|
eb70553fea | ||
|
|
9e4d268b50 | ||
|
|
d92c25ab4e | ||
|
|
9975be4ff7 | ||
|
|
2442f6f2ad | ||
|
|
e39a20f652 | ||
|
|
dcca47f3ca | ||
|
|
df8aaea374 | ||
|
|
c3ac80afa8 | ||
|
|
8d59359ef5 | ||
|
|
53202f4a5b | ||
|
|
792db27252 | ||
|
|
c9bcbcf1d0 | ||
|
|
79a0c02f03 | ||
|
|
2a5dbfb75d | ||
|
|
b7443ff1bb | ||
|
|
5ee9d5787a | ||
|
|
426dc836ca | ||
|
|
d528710379 | ||
|
|
0a4cfa6055 | ||
|
|
1ca85157ab | ||
|
|
d8162a1dc4 | ||
|
|
9487edf05f | ||
|
|
30d95a6582 | ||
|
|
24b580150a | ||
|
|
1b7af2096f | ||
|
|
843f844e2c | ||
|
|
ed7d739291 | ||
|
|
591076bf27 | ||
|
|
891d78c3b6 | ||
|
|
4baee8351b | ||
|
|
0653c2293c | ||
|
|
509bdd08dd | ||
|
|
93db75b835 | ||
|
|
af6970569f | ||
|
|
50708ebe32 | ||
|
|
816bc700ed | ||
|
|
6e8228a82c | ||
|
|
54e4819c71 | ||
|
|
f573db2fe0 | ||
|
|
a45c150a3d | ||
|
|
46a7a9c495 | ||
|
|
09ff459751 | ||
|
|
1d249b8fff | ||
|
|
5eaf828758 | ||
|
|
0eb07f0242 | ||
|
|
023d949577 | ||
|
|
abec857dd2 | ||
|
|
58914d7053 | ||
|
|
c93909523d | ||
|
|
800f3f1453 | ||
|
|
9c1d0b3d56 | ||
|
|
f9f405cc00 | ||
|
|
181439be1d | ||
|
|
1d2328ced1 | ||
|
|
b9b073601b | ||
|
|
feec676632 | ||
|
|
2b0c43677e | ||
|
|
c51ee63008 | ||
|
|
0cd527f6ef | ||
|
|
fead8c3876 | ||
|
|
1e6270df44 | ||
|
|
7c9e9b954d | ||
|
|
6b2a1e7ff1 | ||
|
|
4aa506985f | ||
|
|
496659af0a | ||
|
|
9669b6d54c | ||
|
|
d406f7345c | ||
|
|
f838ef56a6 | ||
|
|
77c5cb40e9 | ||
|
|
0e1a4f0006 | ||
|
|
c4b5c56fe4 | ||
|
|
9066ed0688 | ||
|
|
9a189aa586 | ||
|
|
aa28206f57 | ||
|
|
628be7cb7c | ||
|
|
b74f92c86e | ||
|
|
9190974293 | ||
|
|
ffae4b10b9 | ||
|
|
b64baa2390 | ||
|
|
87c4d38c77 | ||
|
|
bf32cbd3e8 | ||
|
|
6d929d0e7e | ||
|
|
df31d8820c | ||
|
|
4bd5e5a9cc | ||
|
|
f38775bbe5 | ||
|
|
7a6caf2389 | ||
|
|
bbb2f8757e | ||
|
|
1effb6bc58 | ||
|
|
20318d0d13 | ||
|
|
610a5cd40b | ||
|
|
6c30596dd1 | ||
|
|
5eddfbe9b3 | ||
|
|
ed1c448fd7 | ||
|
|
32993af090 | ||
|
|
346643bc6d | ||
|
|
f2ce3165dd | ||
|
|
11d1b3a67a | ||
|
|
74f6f65210 | ||
|
|
557dc1f94c | ||
|
|
1e99192448 | ||
|
|
8031964d3d | ||
|
|
52d99ebf20 | ||
|
|
da7ee0926f | ||
|
|
ae5a1ceae5 | ||
|
|
2503dea30f | ||
|
|
92b79c2195 | ||
|
|
c69a468e35 | ||
|
|
4b4b188827 | ||
|
|
223cbf4020 | ||
|
|
0d1a4effdb | ||
|
|
369d9fcd63 | ||
|
|
8af4a4ecf2 | ||
|
|
b6a15ab96e | ||
|
|
0386df262e | ||
|
|
032b0d3723 | ||
|
|
4cb8789786 | ||
|
|
957aa4e2e2 | ||
|
|
65f11d7c8f | ||
|
|
2950e8c993 | ||
|
|
9976b9617f | ||
|
|
eada826503 | ||
|
|
bff1b6156f | ||
|
|
9cd6b142d7 | ||
|
|
ea644c22f1 | ||
|
|
b6e8d83c3b | ||
|
|
68ac2b80ff | ||
|
|
3f4601efa0 | ||
|
|
6ec2c1e805 | ||
|
|
202ba3c856 | ||
|
|
aa7d9affdb | ||
|
|
10e3adfd18 | ||
|
|
682e445288 | ||
|
|
38b838ec97 | ||
|
|
1cc972ea8b | ||
|
|
f9f1c2a437 | ||
|
|
1c432d8610 | ||
|
|
55c61cbd80 | ||
|
|
27c54461de | ||
|
|
2162ed588a | ||
|
|
593cf4ab5a | ||
|
|
b73bca354a | ||
|
|
60b5665981 | ||
|
|
e109b59a55 | ||
|
|
09c48d76ce | ||
|
|
f73fbcafdd | ||
|
|
02da09633d | ||
|
|
32d8dcf80a | ||
|
|
d520586e87 | ||
|
|
28217968e1 | ||
|
|
21bd24f614 | ||
|
|
8f8235e9d9 | ||
|
|
85b4f63bc7 | ||
|
|
743ffc0cf2 | ||
|
|
7ea8c60e5d | ||
|
|
19e699ea54 | ||
|
|
fa105079d7 | ||
|
|
8d79956f3c | ||
|
|
bfba0258ba | ||
|
|
15e8bf06fd | ||
|
|
0f0becd096 | ||
|
|
9040d50c3c | ||
|
|
899481d752 | ||
|
|
8a1ebeb52d | ||
|
|
4f08cb95e8 | ||
|
|
736c956ec2 | ||
|
|
eb787b2d6f | ||
|
|
8d0d234ac1 | ||
|
|
7fda0db51b | ||
|
|
a1d4853d1c | ||
|
|
6c41a4d0a0 | ||
|
|
cedafc82dc | ||
|
|
443070886e | ||
|
|
3d6111627b | ||
|
|
33a7f50fed | ||
|
|
6ca2f83c05 | ||
|
|
c4f76cea6c | ||
|
|
26d49d7ae0 | ||
|
|
22369825e8 | ||
|
|
27b286c6de | ||
|
|
0c5684905f | ||
|
|
3fb888561b | ||
|
|
f2f0a4ba2b | ||
|
|
50bf6be95f | ||
|
|
486a06baf0 | ||
|
|
214b91a64b | ||
|
|
84aacb0717 | ||
|
|
4c1a4f956e | ||
|
|
9f1d75817e | ||
|
|
3b61c0ba8f | ||
|
|
1706c05f93 | ||
|
|
f3bd3de81c | ||
|
|
91187f4db0 | ||
|
|
0d5ad337f6 | ||
|
|
d3fa9cf98a | ||
|
|
2251d46711 | ||
|
|
962418c37d | ||
|
|
1d633eabdc | ||
|
|
5a0e68b0e8 | ||
|
|
76f9843251 | ||
|
|
1a6a1854fc | ||
|
|
b55bf0918f | ||
|
|
5f9fb5a68f | ||
|
|
469177182a | ||
|
|
a72e580e1f | ||
|
|
9c88ba879d | ||
|
|
9d45dea8b3 | ||
|
|
f1732780cc | ||
|
|
ae70eee7e0 | ||
|
|
03277e240e | ||
|
|
510f7301fd | ||
|
|
32f42a4009 | ||
|
|
267016cff9 | ||
|
|
fc02800a35 | ||
|
|
b33f09c76b | ||
|
|
2d68d1d29a | ||
|
|
ec14798d9c | ||
|
|
a2e438ceea | ||
|
|
e606e56ce7 | ||
|
|
a8a75f6e97 | ||
|
|
35e07b321c | ||
|
|
28ce5762f8 | ||
|
|
97367afed1 | ||
|
|
d1b229c459 | ||
|
|
335f73a0e6 | ||
|
|
ef38c4a882 | ||
|
|
132b631a36 | ||
|
|
d1ccbc6541 | ||
|
|
7807c12114 | ||
|
|
779032be50 | ||
|
|
e78d08a6d4 | ||
|
|
4301cd4806 | ||
|
|
5ab41a5ebb | ||
|
|
e7c09b6f2f | ||
|
|
bf63bd54df | ||
|
|
f486a664ca | ||
|
|
41118eda97 | ||
|
|
cd96171a4c | ||
|
|
83c1abd707 | ||
|
|
574d3ec70e | ||
|
|
18c1bb1ad4 | ||
|
|
8ea94765ce | ||
|
|
8d7a5f22fd | ||
|
|
a31ff74999 | ||
|
|
c60979f1ab | ||
|
|
b5739efbbc | ||
|
|
96e014e6ca | ||
|
|
d0020b8a90 | ||
|
|
f32b86e7e7 | ||
|
|
7cab5d9815 | ||
|
|
a6a22aa393 | ||
|
|
fd098a0766 | ||
|
|
36326526d5 | ||
|
|
f20a903e78 | ||
|
|
0f0564066d | ||
|
|
7b6a4d3b30 | ||
|
|
175c450559 | ||
|
|
5b79141dd5 | ||
|
|
97de6543c2 | ||
|
|
bc424265e0 | ||
|
|
15d057b63c | ||
|
|
27a121c270 | ||
|
|
d99a4bc3ff | ||
|
|
d6251adae3 | ||
|
|
388d3e9369 | ||
|
|
f8f0800f97 | ||
|
|
9b9d87757c | ||
|
|
247a7c4da6 | ||
|
|
f2d87fa801 | ||
|
|
e7534c9b15 | ||
|
|
edd0ec5f68 | ||
|
|
a76bd2cab2 | ||
|
|
25d9d562e6 | ||
|
|
e8c331dfff | ||
|
|
afa96c330b | ||
|
|
4992cdda74 | ||
|
|
c2a1a1b1d2 | ||
|
|
89cce5160c | ||
|
|
5f0bb46bf4 | ||
|
|
f4551a92c5 | ||
|
|
e9c197f46e | ||
|
|
3a756630f3 | ||
|
|
a77bfd2aca | ||
|
|
1cebc1fed8 | ||
|
|
1a90876500 | ||
|
|
c4065b95b8 | ||
|
|
1a74d6a280 | ||
|
|
0523920fd0 | ||
|
|
a8ae2e551e | ||
|
|
aa0df7be79 | ||
|
|
49ddcda2b6 | ||
|
|
d173d5584a | ||
|
|
1679a67dc6 | ||
|
|
31163a1b2e | ||
|
|
5f32e46759 | ||
|
|
91bdb02867 | ||
|
|
f8aa6cab78 | ||
|
|
8a62ff38af | ||
|
|
8350752f56 | ||
|
|
a182bee57f | ||
|
|
c5a8df19ae | ||
|
|
38b6674cc1 | ||
|
|
73b2c36bbc | ||
|
|
672c5eb733 | ||
|
|
948ce99482 | ||
|
|
3791e2ae70 | ||
|
|
68d1172b8f | ||
|
|
c94e61bfce | ||
|
|
8b557a5963 | ||
|
|
d4f99a411c | ||
|
|
ea2e44a2be | ||
|
|
4884ca0bd6 | ||
|
|
2a7c9ef9e8 | ||
|
|
cb3f67f231 | ||
|
|
00e3bd12cc | ||
|
|
d539bc075f | ||
|
|
818fd6fcdd | ||
|
|
5dc3a364a4 | ||
|
|
840eb664ce | ||
|
|
51823d1b88 | ||
|
|
8e0536ee11 | ||
|
|
5648e5eb77 | ||
|
|
940e8d24c7 | ||
|
|
4813a861a6 | ||
|
|
afb30a256e | ||
|
|
499888b4a7 | ||
|
|
227359da62 | ||
|
|
022b14c830 | ||
|
|
58f970c6b1 | ||
|
|
d83ce2f276 | ||
|
|
0c8f36bc36 | ||
|
|
1286ecfc8f | ||
|
|
24ebe94730 | ||
|
|
90b0e0d7b6 | ||
|
|
ebd115c129 | ||
|
|
5f544416ef | ||
|
|
5a7a7a7257 | ||
|
|
8e8bf4ed5e | ||
|
|
7244f69c7f | ||
|
|
d5b992d736 | ||
|
|
38cb777458 | ||
|
|
1a569d45de | ||
|
|
93a710f8c5 | ||
|
|
5414bbb190 | ||
|
|
c70aac4f31 | ||
|
|
9471966bee | ||
|
|
055a39cc0d | ||
|
|
4f075d4b23 | ||
|
|
526c43c655 | ||
|
|
084b446ae1 | ||
|
|
de9c04ff18 | ||
|
|
2fb09126c0 | ||
|
|
8944295474 | ||
|
|
85348188d2 | ||
|
|
a68b405c61 | ||
|
|
2a18a556bf | ||
|
|
fa14d4ad15 | ||
|
|
44deb5878c | ||
|
|
2601c1d059 | ||
|
|
7e51d96379 | ||
|
|
4c1eaf507d | ||
|
|
76149b0c79 | ||
|
|
564d2b1ac5 | ||
|
|
faef5cb866 | ||
|
|
3ed2c4812a | ||
|
|
4c53dd6ea3 | ||
|
|
5010cccc71 | ||
|
|
aeb3f150c5 | ||
|
|
995f356419 | ||
|
|
c64dcb43b4 | ||
|
|
7dafba9fc3 | ||
|
|
abc501825a | ||
|
|
3ad2a56efb | ||
|
|
c53e486bf0 | ||
|
|
819a669922 | ||
|
|
d852fe1e9f | ||
|
|
d431d5b4b1 | ||
|
|
ba99d14f68 | ||
|
|
7e42599b49 | ||
|
|
c38c190d42 | ||
|
|
6e8d869afb | ||
|
|
63d6062673 | ||
|
|
af830b8782 | ||
|
|
0605cf5fd0 | ||
|
|
4acb4dd3a7 | ||
|
|
72c31b9f21 | ||
|
|
f2b1c24d19 | ||
|
|
f86bb0ada2 | ||
|
|
ec4866f39a | ||
|
|
7961b614f1 | ||
|
|
901d84d070 | ||
|
|
0144bc109a | ||
|
|
ca5d82f5be | ||
|
|
b6a6152a49 | ||
|
|
5e19a83f8a | ||
|
|
cd30e9d533 | ||
|
|
ea0011771b | ||
|
|
94b0077b2a | ||
|
|
ea815d0147 | ||
|
|
0ae048d396 | ||
|
|
972558396e | ||
|
|
c218241e80 | ||
|
|
e616e69aa4 | ||
|
|
2627471b23 | ||
|
|
a2c42ee5a7 | ||
|
|
8d89caeba7 | ||
|
|
b0393b532c | ||
|
|
2ea875f2cb | ||
|
|
d1d2900847 | ||
|
|
98d851d76d | ||
|
|
30fdc1db4c | ||
|
|
12df15154d | ||
|
|
0bf6c01f91 | ||
|
|
497263f367 | ||
|
|
cd458d6d22 | ||
|
|
a2b55166ed | ||
|
|
570d31b6d1 | ||
|
|
2483dd6828 | ||
|
|
1adef98c57 | ||
|
|
dfd3c8f2bf | ||
|
|
015c592978 | ||
|
|
8984cea367 | ||
|
|
c3cf63dfb7 | ||
|
|
c532b61ef6 | ||
|
|
491fbb7801 | ||
|
|
4c7a40d8bc | ||
|
|
c866d1e836 | ||
|
|
c72160b2a4 | ||
|
|
3143f14e0b | ||
|
|
e06a365029 | ||
|
|
36e79e49da | ||
|
|
5bee7022b2 | ||
|
|
e578513eaf | ||
|
|
8de2a69a99 | ||
|
|
890299c2f3 | ||
|
|
2a6f1c402b | ||
|
|
25d2b1889f | ||
|
|
ee843fae26 | ||
|
|
fea35205b7 | ||
|
|
ee28543180 | ||
|
|
4d7a66f1d8 | ||
|
|
3b9ff8d58f | ||
|
|
b46d5a4e5e | ||
|
|
23a823f2bb | ||
|
|
56ef00536a | ||
|
|
0081006a64 | ||
|
|
1c98d81c2c | ||
|
|
7e9d9a5fed | ||
|
|
45826e0a88 | ||
|
|
8815433962 | ||
|
|
e4261820d4 | ||
|
|
5bfcaf4809 | ||
|
|
7f4f6ff651 | ||
|
|
7d9b7e92fb | ||
|
|
63963414c1 | ||
|
|
f0ee245f75 | ||
|
|
a4710a8b85 | ||
|
|
201ad61e6d | ||
|
|
bbb9eefc9a | ||
|
|
d53b503805 | ||
|
|
b1d90952c4 | ||
|
|
742bd6e213 | ||
|
|
e0abb53d4c | ||
|
|
8e08e6f415 | ||
|
|
e883ea1346 | ||
|
|
0327b37d2f | ||
|
|
699feead15 | ||
|
|
a08fabaed9 | ||
|
|
61e276df37 | ||
|
|
076762e2f3 | ||
|
|
9761989ea4 | ||
|
|
5ae7698704 | ||
|
|
c84ac29332 | ||
|
|
cf8a1ce8a3 | ||
|
|
1507701981 | ||
|
|
b4796efed1 | ||
|
|
273f7d7f07 | ||
|
|
8474630f4c | ||
|
|
907ec5fa3c | ||
|
|
43001bee9f | ||
|
|
5479a6b885 | ||
|
|
594f7922f9 | ||
|
|
7aa9f768ea | ||
|
|
b796a30e1f | ||
|
|
5508eb013a | ||
|
|
a3c7af7c8c | ||
|
|
d689bf21d2 | ||
|
|
05ea067361 | ||
|
|
2af8710a6f | ||
|
|
6abc9f7f9f | ||
|
|
7de21b7015 | ||
|
|
f9bae5d8ff | ||
|
|
29322577aa | ||
|
|
a4f96c7c5b | ||
|
|
b3fe85b8a0 | ||
|
|
efc4213363 | ||
|
|
b983648f35 | ||
|
|
e62eac91d1 | ||
|
|
e6f1bd50db | ||
|
|
c5458fb29c | ||
|
|
8e3daf482d | ||
|
|
03edd1a011 | ||
|
|
246ed83a3d | ||
|
|
53440476c4 | ||
|
|
14f3fd3cd7 | ||
|
|
6c9d56808b | ||
|
|
08b3107d92 | ||
|
|
dc8fa27ea1 | ||
|
|
445d7050b7 | ||
|
|
bfe0df4df3 | ||
|
|
71bcca71cd | ||
|
|
53864ac12c | ||
|
|
f438500a57 | ||
|
|
cef8aacf2f | ||
|
|
1ab414c4bf | ||
|
|
fdffd7bf3a | ||
|
|
6fc52aba6d | ||
|
|
dfd4cc068a | ||
|
|
5ae21c8580 | ||
|
|
178a934fa3 | ||
|
|
1ce508a4b1 | ||
|
|
d0678ec7c2 | ||
|
|
8036ffeafc | ||
|
|
a5a37c6181 | ||
|
|
945cdc0ae3 | ||
|
|
8de2e89b68 | ||
|
|
0f49470bf6 | ||
|
|
7826565ce7 | ||
|
|
713571469b | ||
|
|
2f34fdd409 | ||
|
|
6fefa16ac3 | ||
|
|
b344f412c9 | ||
|
|
1e245e7719 | ||
|
|
28facd66c4 | ||
|
|
c8f01f08ed | ||
|
|
a89525f77e | ||
|
|
38a2fa87c3 | ||
|
|
46e6ed2e6f | ||
|
|
266952c404 | ||
|
|
b77e59589a | ||
|
|
1a5ae397dc | ||
|
|
7c4718ad02 | ||
|
|
d79341b6d9 | ||
|
|
84738ba00c | ||
|
|
3b0a8d8e4b | ||
|
|
2584cca128 | ||
|
|
05b8cea206 | ||
|
|
6e3d23a8e1 | ||
|
|
2a38d0fb5f | ||
|
|
97724c776b | ||
|
|
9b072058cc | ||
|
|
d2e65feaa6 | ||
|
|
37c2d3a2b0 | ||
|
|
0909423fe9 | ||
|
|
139bcb101c | ||
|
|
0f06715d0c | ||
|
|
4a783fcba8 | ||
|
|
fd38f0ac98 | ||
|
|
ed2208fe75 | ||
|
|
4320142132 | ||
|
|
160649bf97 | ||
|
|
e7a2efd14a | ||
|
|
81614418d4 | ||
|
|
bb08f3d377 | ||
|
|
5232da6ec3 | ||
|
|
e59547da30 | ||
|
|
de85ccfc51 | ||
|
|
f82019e510 | ||
|
|
7fc26a5202 | ||
|
|
dcb1e47564 | ||
|
|
61259f3d2e | ||
|
|
2dd2608c09 | ||
|
|
81dc4e1138 | ||
|
|
cb1f9f760c | ||
|
|
51530b7608 | ||
|
|
4e6d4a1d77 | ||
|
|
efc5dd93e9 | ||
|
|
210a9d8d06 | ||
|
|
9bd855ee2e | ||
|
|
970c215f40 | ||
|
|
7d157046ac | ||
|
|
1ae7018f79 | ||
|
|
6802567291 | ||
|
|
cbc127e947 | ||
|
|
d91d4765b5 | ||
|
|
328959cc39 | ||
|
|
36320f61ab | ||
|
|
faeacb9a7d | ||
|
|
5dcd416007 | ||
|
|
33e4072430 | ||
|
|
c7fbd6f8f1 | ||
|
|
a5d3694386 | ||
|
|
b3075416e2 | ||
|
|
743d97d690 | ||
|
|
2cb09dde4b | ||
|
|
d7ba5a5f62 | ||
|
|
a00bba35f8 | ||
|
|
71db65d21c | ||
|
|
37d820a67c | ||
|
|
4f02f0a4d7 | ||
|
|
f4b81b3761 | ||
|
|
0be737914a | ||
|
|
1b0d966db0 | ||
|
|
27f87883f7 | ||
|
|
f747e076ab | ||
|
|
4b12918ba5 | ||
|
|
c104b1b8b4 | ||
|
|
9d4106cd81 | ||
|
|
eddf8c9295 | ||
|
|
6b3e42a44e | ||
|
|
9b1d4832b6 | ||
|
|
4d2f054e40 | ||
|
|
6450b69ae7 | ||
|
|
223187c7ea | ||
|
|
3b34f73cb3 | ||
|
|
40c5c92230 | ||
|
|
6760798f18 | ||
|
|
42ea1ad16e | ||
|
|
96200aebe6 | ||
|
|
bcfe4b6a49 | ||
|
|
cb92e5e68d | ||
|
|
1fa6b5bb51 | ||
|
|
cafc4fb22f | ||
|
|
39eb5600d9 | ||
|
|
0b97462dc9 | ||
|
|
cab80edf60 | ||
|
|
6f3b58d1a5 | ||
|
|
fc89c7b93c | ||
|
|
4a57c4eb84 | ||
|
|
96cbdea820 | ||
|
|
a3a54aef94 | ||
|
|
c96e5ff6c5 | ||
|
|
144470877d | ||
|
|
391844512a | ||
|
|
d89c304b13 | ||
|
|
881126c7f1 | ||
|
|
5bbec00803 | ||
|
|
7730e46cfc | ||
|
|
97b2253e9d | ||
|
|
1afb2a783b | ||
|
|
0fdbfa3ad4 | ||
|
|
a7dc5e05b3 | ||
|
|
92d7280728 | ||
|
|
5d01b88a1e | ||
|
|
2b47e3f4c9 | ||
|
|
485360f291 | ||
|
|
17fdbb05ce | ||
|
|
adc22efcb1 | ||
|
|
4c70b1a06b | ||
|
|
4f58aa110a | ||
|
|
4d3fe722e8 | ||
|
|
6320e580ae | ||
|
|
611f4cde70 | ||
|
|
6d3268a61e | ||
|
|
bf0a1ecebd | ||
|
|
693c086930 | ||
|
|
7c307a9134 | ||
|
|
aae7e0e36c | ||
|
|
2014a3d6de | ||
|
|
2e5c1bc3b5 | ||
|
|
ac13b7a3bd | ||
|
|
6b7bdf5afb | ||
|
|
3eba628a8b | ||
|
|
9949478b36 | ||
|
|
ff657ec34c | ||
|
|
da4698d431 | ||
|
|
20d47ae323 | ||
|
|
f4f799f636 | ||
|
|
cc411f036d | ||
|
|
62d5c2a91f | ||
|
|
8350c5ee36 | ||
|
|
65435cf2b5 | ||
|
|
af4c64e633 | ||
|
|
41b913debe | ||
|
|
a3b9368953 | ||
|
|
28ece820ed | ||
|
|
cca420b1a0 | ||
|
|
05803c79b4 | ||
|
|
5932ccafec | ||
|
|
7cee017e20 | ||
|
|
b15a8d9c8a | ||
|
|
7e6d5c3e54 | ||
|
|
dd3d297dab | ||
|
|
e4f728d809 | ||
|
|
cd7bab9184 | ||
|
|
ec6b1558b1 | ||
|
|
1c3ee8b557 | ||
|
|
1db7f69f89 | ||
|
|
3c1ce1fe27 | ||
|
|
2d05b6a282 | ||
|
|
b5ed9692df | ||
|
|
9a326d791b | ||
|
|
7fbd240d97 | ||
|
|
58d4691354 | ||
|
|
594295b7c8 | ||
|
|
2c45673f54 | ||
|
|
7827afe606 | ||
|
|
bc7498e02b | ||
|
|
e5dd85aefb | ||
|
|
cf1fce3dc0 | ||
|
|
480cc07cd9 | ||
|
|
84d4327e80 | ||
|
|
34102ef6ef | ||
|
|
ca985a0b76 | ||
|
|
4b4a154261 | ||
|
|
e957327877 | ||
|
|
2fdea90ad4 | ||
|
|
ad1aee9c9e | ||
|
|
bd7451e86f | ||
|
|
0230360145 | ||
|
|
eee1190f10 | ||
|
|
96c8aae01e | ||
|
|
0ad65be961 | ||
|
|
0f8d484e28 | ||
|
|
364c369199 | ||
|
|
901bcb8460 | ||
|
|
d06ac91052 | ||
|
|
f818a4c1d6 | ||
|
|
85191e10c8 | ||
|
|
001a3231e1 | ||
|
|
d951dff5a9 | ||
|
|
dc82f837aa | ||
|
|
7f1db0b444 | ||
|
|
a317950567 | ||
|
|
4c7269e357 | ||
|
|
15fd763de8 | ||
|
|
0c314674fc | ||
|
|
efd03141f0 | ||
|
|
675bcb549d | ||
|
|
56425254a9 | ||
|
|
22856351fd | ||
|
|
dd1229309f | ||
|
|
fad7c5985c | ||
|
|
3234102e55 | ||
|
|
fb2f105520 | ||
|
|
03abe0b5cd | ||
|
|
6873c6db4e | ||
|
|
08bab927a2 | ||
|
|
d244567b30 | ||
|
|
2b1b21d2e2 | ||
|
|
12213de478 | ||
|
|
2a2c832e0b | ||
|
|
b534aae70b | ||
|
|
6d3e4406ae | ||
|
|
463d60b650 | ||
|
|
e9812495e9 | ||
|
|
0a836c78bb | ||
|
|
5c7f835e4c | ||
|
|
346849631e | ||
|
|
bf166bdaad | ||
|
|
123f183f68 | ||
|
|
bea1505c63 | ||
|
|
fac5ed5579 | ||
|
|
96fefbd8a3 | ||
|
|
548018997e | ||
|
|
9f3477e1cd | ||
|
|
baa03246e6 | ||
|
|
a17b1cd0e2 | ||
|
|
49d82870c4 | ||
|
|
6a72a4467a | ||
|
|
efbed6e0b6 | ||
|
|
5270670b65 | ||
|
|
b82f4ca02b | ||
|
|
0f451c7e3a | ||
|
|
824dc8dcdd | ||
|
|
20405be86c | ||
|
|
4edfa951dc | ||
|
|
029c16d1d0 | ||
|
|
b3acff8cba | ||
|
|
a98b0e3a00 | ||
|
|
b1cbcbd98d | ||
|
|
e079980598 | ||
|
|
2e27c0459c | ||
|
|
d87a237789 | ||
|
|
e9e0aa357b | ||
|
|
9af300bba8 | ||
|
|
7d79cbf5bd | ||
|
|
fdca27bb81 | ||
|
|
01f0213693 | ||
|
|
5d29a49120 | ||
|
|
33e6b80d5a | ||
|
|
100d931535 | ||
|
|
dba6dd1983 | ||
|
|
dc8898e1da | ||
|
|
ed79bf55eb | ||
|
|
fb644f5fbe | ||
|
|
ab409dfd2c | ||
|
|
42285dd911 | ||
|
|
db2a6c65b7 | ||
|
|
c6ad10857a | ||
|
|
f128a55f97 | ||
|
|
2a817e5861 | ||
|
|
5e616f1a50 | ||
|
|
c6e9d71e1f | ||
|
|
f58d44bf9c | ||
|
|
f72ab39c93 | ||
|
|
6c706e6162 | ||
|
|
8f81d207b8 | ||
|
|
b74c5f384d | ||
|
|
df412d51fe | ||
|
|
8942bb7e48 | ||
|
|
ca60679126 | ||
|
|
8db846ad5d | ||
|
|
bb6a90058b | ||
|
|
44df09fac2 | ||
|
|
e214ce8bfb | ||
|
|
6d281922e4 | ||
|
|
58f09331b0 | ||
|
|
9780a6b63e | ||
|
|
71f764c224 | ||
|
|
9362997246 | ||
|
|
28ea88f61e | ||
|
|
a25ff14dd4 | ||
|
|
d86caee7af | ||
|
|
c4caabe722 | ||
|
|
8dcb77634b | ||
|
|
c4feed116d | ||
|
|
e220767179 | ||
|
|
571126c36d | ||
|
|
832323f25e | ||
|
|
3b73432d8c | ||
|
|
3aa341370b | ||
|
|
895da1a812 | ||
|
|
d34ee6fe48 | ||
|
|
7298fe378c | ||
|
|
2da0b48c29 | ||
|
|
165509f5a0 | ||
|
|
c9b9dbb092 | ||
|
|
0cc1d5da8f | ||
|
|
c70dced268 | ||
|
|
df698387dc | ||
|
|
716dc056d6 | ||
|
|
cf91f3f72a | ||
|
|
51b87d0ac6 | ||
|
|
c83d7adddd | ||
|
|
549665bc61 | ||
|
|
7f5f43f0c2 | ||
|
|
af41469d58 | ||
|
|
43e1309bd8 | ||
|
|
91f7cf05fc | ||
|
|
875431a620 | ||
|
|
db0c0d98bc | ||
|
|
5406f4a11b | ||
|
|
bfdd3273fe | ||
|
|
8798103e7e | ||
|
|
203b5ab1ac | ||
|
|
ed1b26207b | ||
|
|
e0166a08e2 | ||
|
|
8af4e9b5e8 | ||
|
|
900c1d3570 | ||
|
|
b95a17a4e0 | ||
|
|
0f0b012a44 | ||
|
|
b291f82e4d | ||
|
|
86b7222916 | ||
|
|
7a12b80dd2 | ||
|
|
4a836a58ee | ||
|
|
b47fc5b93b | ||
|
|
f3b9103a51 | ||
|
|
dc3ccdddd4 | ||
|
|
807eb4a7d9 | ||
|
|
a24283eb5e | ||
|
|
fd7116b2e1 | ||
|
|
2e1289df28 | ||
|
|
6064ca5a4f | ||
|
|
3db1b3c0f3 | ||
|
|
06ffa203ef | ||
|
|
dd1db8f782 | ||
|
|
fe8e309399 | ||
|
|
e7a20374c7 | ||
|
|
4cfa0f512b | ||
|
|
64b4f6b759 | ||
|
|
2d421e6052 | ||
|
|
cd8dd683fa | ||
|
|
a2bdc7ab93 | ||
|
|
d4132c2411 | ||
|
|
4c6e273268 | ||
|
|
043f174e05 | ||
|
|
26e9fac1ed | ||
|
|
88f33db249 | ||
|
|
55a67bbc0c | ||
|
|
08f042589d | ||
|
|
52f540a014 | ||
|
|
e85ef93480 | ||
|
|
a757f80263 | ||
|
|
b9b8ff0e10 | ||
|
|
e0aad074ec | ||
|
|
ad88b4e046 | ||
|
|
5156814e7a | ||
|
|
f988d16215 | ||
|
|
f5a3fccad3 | ||
|
|
e3f0079578 | ||
|
|
b831ea3c47 | ||
|
|
a88545b8b9 | ||
|
|
44523a0392 | ||
|
|
dbc207a9a6 | ||
|
|
e68d861ee5 | ||
|
|
7851bbadfa | ||
|
|
9223d00af3 | ||
|
|
740c21532a | ||
|
|
9fdd8bbc17 | ||
|
|
0978fa58a2 | ||
|
|
1395a12d32 | ||
|
|
9aab0e7417 | ||
|
|
ddc5810c71 | ||
|
|
21c349e1d7 | ||
|
|
a7784c2985 | ||
|
|
0cc69d90f0 | ||
|
|
f125737d30 | ||
|
|
18aef2376a | ||
|
|
c8287ff107 | ||
|
|
baf344a697 | ||
|
|
8c94049e3c | ||
|
|
646c76c3cb | ||
|
|
adbb9f5765 | ||
|
|
d3a6cc968f | ||
|
|
d6ff40cc6a | ||
|
|
fdd6c46b5f | ||
|
|
26c892c2a0 | ||
|
|
3516e1ff44 | ||
|
|
0047077074 | ||
|
|
8459fac184 | ||
|
|
afe828f012 | ||
|
|
60ed0a2043 | ||
|
|
2c9bc07dec | ||
|
|
91ba11b565 | ||
|
|
8f79427720 | ||
|
|
32f3aaf38f | ||
|
|
76aaf7f37d | ||
|
|
7d37c606cc | ||
|
|
6bce89f277 | ||
|
|
5420fcfe29 | ||
|
|
8507c20481 | ||
|
|
914dd8bf93 | ||
|
|
960ebdc727 | ||
|
|
74ef956638 | ||
|
|
a6323f42af | ||
|
|
bc1c20c91f | ||
|
|
43297373ed | ||
|
|
5228755f7f | ||
|
|
7ded0a0742 | ||
|
|
d74ff9ab62 | ||
|
|
6ef27106df | ||
|
|
35a27101c1 | ||
|
|
6fbe75c8ad | ||
|
|
89fd754efc | ||
|
|
576763fe5b | ||
|
|
c67ec08e1a | ||
|
|
6f49573f2f | ||
|
|
12c3290f19 | ||
|
|
53c0706a3a | ||
|
|
556386e446 | ||
|
|
07b2d9f792 | ||
|
|
a2081da296 | ||
|
|
dde7db9489 | ||
|
|
f947f55fc6 | ||
|
|
7bbac6c703 | ||
|
|
420ecb6147 | ||
|
|
dcb2787498 | ||
|
|
336083a00f | ||
|
|
727d0a9acd | ||
|
|
29894ffcca | ||
|
|
e804fa39ba | ||
|
|
f866284240 | ||
|
|
9e3124d29e | ||
|
|
d7e0eb09b3 | ||
|
|
5fcd447bc8 | ||
|
|
6f04b85663 | ||
|
|
47262761fe | ||
|
|
b46e7a2185 | ||
|
|
2d484c1ad2 | ||
|
|
275fa90a4d | ||
|
|
f8956c70bf | ||
|
|
39fa9c78f8 | ||
|
|
d96a29543e | ||
|
|
d2d4abe91f | ||
|
|
5f567cf138 | ||
|
|
7bf7d94127 | ||
|
|
5b8d0d2aeb | ||
|
|
d37e585205 | ||
|
|
a30503ca5f | ||
|
|
e65899e384 | ||
|
|
16a3f3d66c | ||
|
|
1e2f325c55 | ||
|
|
ccd240f4e8 | ||
|
|
7b34c5eb66 | ||
|
|
6da3761e76 | ||
|
|
b03abdd2eb | ||
|
|
6ea4e985ef | ||
|
|
699d6cb08c | ||
|
|
ac70deaf19 | ||
|
|
4907db44eb | ||
|
|
81154d1f50 | ||
|
|
5eb46f6b6c | ||
|
|
001a6617f5 | ||
|
|
c009373a43 | ||
|
|
cef20e37c2 | ||
|
|
20d16c6a32 | ||
|
|
2fc3daee70 | ||
|
|
a7955ba9c5 | ||
|
|
84e773eab9 | ||
|
|
da4d3032be | ||
|
|
d89e71ac2f | ||
|
|
de806ee6d9 | ||
|
|
9c45877999 | ||
|
|
2059ecdb40 | ||
|
|
52d66b5de4 | ||
|
|
fb9b026ad6 | ||
|
|
8f1b6f6b67 | ||
|
|
0bd448a399 | ||
|
|
2b395a05ea | ||
|
|
dce17de000 | ||
|
|
3881179f72 | ||
|
|
da0a502756 | ||
|
|
cbf00e29ac | ||
|
|
a466766c5c | ||
|
|
a4781509c4 | ||
|
|
8a9361d822 | ||
|
|
e2522a492a | ||
|
|
bab551c511 | ||
|
|
c63bb16704 | ||
|
|
fa56dc4791 | ||
|
|
e92ee3b730 | ||
|
|
bb794f4887 | ||
|
|
a227389e3e | ||
|
|
d9f0b067ca | ||
|
|
c0b708462a | ||
|
|
adb0dfff47 | ||
|
|
6139cb5cb9 | ||
|
|
61d7924c54 | ||
|
|
899b09ac40 | ||
|
|
debc9fc1cb | ||
|
|
5c76adedf3 | ||
|
|
1ebb26e4c2 | ||
|
|
67378c1f52 | ||
|
|
469a22ef5f | ||
|
|
fdceb51fdc | ||
|
|
97a132e05f | ||
|
|
26fabddcbe | ||
|
|
40370067b2 | ||
|
|
f0bf6962e7 | ||
|
|
3b432a0209 | ||
|
|
c7a03922a0 | ||
|
|
e70b4c091e | ||
|
|
7e38d5e405 | ||
|
|
f810e82b45 | ||
|
|
dff9f93a6b | ||
|
|
c4109ad6ac | ||
|
|
7a6efb900e | ||
|
|
e080e47a35 | ||
|
|
82599f91d8 | ||
|
|
8fa156f625 | ||
|
|
55112cefa9 | ||
|
|
152c7c8a91 | ||
|
|
aa1c0da80e | ||
|
|
87174f207d | ||
|
|
400f879d29 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,5 @@ dev-dist
|
|||||||
tags
|
tags
|
||||||
node_modules
|
node_modules
|
||||||
crm/public/frontend
|
crm/public/frontend
|
||||||
frontend/yarn.lock
|
|
||||||
crm/www/crm.html
|
crm/www/crm.html
|
||||||
build
|
build
|
||||||
|
|||||||
45
.mergify.yml
Normal file
45
.mergify.yml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
pull_request_rules:
|
||||||
|
- name: Auto-close PRs on stable branch
|
||||||
|
conditions:
|
||||||
|
- and:
|
||||||
|
- and:
|
||||||
|
- author!=shariquerik
|
||||||
|
- author!=frappe-pr-bot
|
||||||
|
- author!=mergify[bot]
|
||||||
|
- or:
|
||||||
|
- base=main
|
||||||
|
actions:
|
||||||
|
close:
|
||||||
|
comment:
|
||||||
|
message: |
|
||||||
|
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
|
||||||
|
|
||||||
|
- name: backport to develop
|
||||||
|
conditions:
|
||||||
|
- label="backport develop"
|
||||||
|
actions:
|
||||||
|
backport:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
assignees:
|
||||||
|
- "{{ author }}"
|
||||||
|
|
||||||
|
- name: backport to main-hotfix
|
||||||
|
conditions:
|
||||||
|
- label="backport main-hotfix"
|
||||||
|
actions:
|
||||||
|
backport:
|
||||||
|
branches:
|
||||||
|
- main-hotfix
|
||||||
|
assignees:
|
||||||
|
- "{{ author }}"
|
||||||
|
|
||||||
|
- name: backport to main
|
||||||
|
conditions:
|
||||||
|
- label="backport main"
|
||||||
|
actions:
|
||||||
|
backport:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
assignees:
|
||||||
|
- "{{ author }}"
|
||||||
11
README.md
11
README.md
@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**Simplify Sales, Amplify Relationships**
|
**Simplify Sales, Amplify Relationships**
|
||||||
|
|
||||||

|
[](https://github.com/frappe/crm/releases)
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<picture>
|
<picture>
|
||||||
@ -84,6 +84,14 @@ The motivation behind building Frappe CRM stems from the need for a simple, cust
|
|||||||
- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework.
|
- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework.
|
||||||
- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
This app is compatible with the following versions of Frappe and ERPNext:
|
||||||
|
|
||||||
|
| CRM branch | Stability | Frappe branch | ERPNext branch |
|
||||||
|
| :-------------------- | :-------- | :------------------- | :------------------- |
|
||||||
|
| main - v1.x | stable | v15.x | v15.x |
|
||||||
|
| develop - future/v2.x | unstable | develop - future/v16 | develop - future/v16 |
|
||||||
|
|
||||||
## Getting Started (Production)
|
## Getting Started (Production)
|
||||||
|
|
||||||
### Managed Hosting
|
### Managed Hosting
|
||||||
@ -181,6 +189,7 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
|
|||||||
- [Discuss Forum](https://discuss.frappe.io/c/frappe-crm)
|
- [Discuss Forum](https://discuss.frappe.io/c/frappe-crm)
|
||||||
- [Documentation](https://docs.frappe.io/crm)
|
- [Documentation](https://docs.frappe.io/crm)
|
||||||
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
|
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
|
||||||
|
- [X/Twitter](https://x.com/frappetech)
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
from bs4 import BeautifulSoup
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.translate import get_all_translations
|
from bs4 import BeautifulSoup
|
||||||
from frappe.utils import validate_email_address, split_emails, cstr
|
|
||||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
|
||||||
from frappe.core.api.file import get_max_file_size
|
from frappe.core.api.file import get_max_file_size
|
||||||
|
from frappe.translate import get_all_translations
|
||||||
|
from frappe.utils import cstr, split_emails, validate_email_address
|
||||||
|
from frappe.utils.modules import get_modules_from_all_apps_for_user
|
||||||
|
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@ -63,9 +64,14 @@ def check_app_permission():
|
|||||||
if frappe.session.user == "Administrator":
|
if frappe.session.user == "Administrator":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
allowed_modules = get_modules_from_all_apps_for_user()
|
||||||
|
allowed_modules = [x["module_name"] for x in allowed_modules]
|
||||||
|
if "FCRM" not in allowed_modules:
|
||||||
|
return False
|
||||||
|
|
||||||
roles = frappe.get_roles()
|
roles = frappe.get_roles()
|
||||||
if any(
|
if any(
|
||||||
role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles
|
role in ["System Manager", "Sales User", "Sales Manager"] for role in roles
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -93,9 +99,14 @@ def accept_invitation(key: str | None = None):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def invite_by_email(emails: str, role: str):
|
def invite_by_email(emails: str, role: str):
|
||||||
frappe.only_for("Sales Manager")
|
frappe.only_for(["Sales Manager", "System Manager"])
|
||||||
|
|
||||||
|
if role not in ["System Manager", "Sales Manager", "Sales User"]:
|
||||||
|
frappe.throw("Cannot invite for this role")
|
||||||
|
|
||||||
if not emails:
|
if not emails:
|
||||||
return
|
return
|
||||||
|
|
||||||
email_string = validate_email_address(emails, throw=False)
|
email_string = validate_email_address(emails, throw=False)
|
||||||
email_list = split_emails(email_string)
|
email_list = split_emails(email_string)
|
||||||
if not email_list:
|
if not email_list:
|
||||||
@ -103,7 +114,10 @@ def invite_by_email(emails: str, role: str):
|
|||||||
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
|
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
|
||||||
existing_invites = frappe.db.get_all(
|
existing_invites = frappe.db.get_all(
|
||||||
"CRM Invitation",
|
"CRM Invitation",
|
||||||
filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]},
|
filters={
|
||||||
|
"email": ["in", email_list],
|
||||||
|
"role": ["in", ["System Manager", "Sales Manager", "Sales User"]],
|
||||||
|
},
|
||||||
pluck="email",
|
pluck="email",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -112,6 +126,12 @@ def invite_by_email(emails: str, role: str):
|
|||||||
for email in to_invite:
|
for email in to_invite:
|
||||||
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"existing_members": existing_members,
|
||||||
|
"existing_invites": existing_invites,
|
||||||
|
"to_invite": to_invite,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_file_uploader_defaults(doctype: str):
|
def get_file_uploader_defaults(doctype: str):
|
||||||
|
|||||||
@ -124,6 +124,7 @@ def get_deal_activities(name):
|
|||||||
activity = {
|
activity = {
|
||||||
"activity_type": "communication",
|
"activity_type": "communication",
|
||||||
"communication_type": communication.communication_type,
|
"communication_type": communication.communication_type,
|
||||||
|
"communication_date": communication.communication_date or communication.creation,
|
||||||
"creation": communication.creation,
|
"creation": communication.creation,
|
||||||
"data": {
|
"data": {
|
||||||
"subject": communication.subject,
|
"subject": communication.subject,
|
||||||
@ -255,6 +256,7 @@ def get_lead_activities(name):
|
|||||||
activity = {
|
activity = {
|
||||||
"activity_type": "communication",
|
"activity_type": "communication",
|
||||||
"communication_type": communication.communication_type,
|
"communication_type": communication.communication_type,
|
||||||
|
"communication_date": communication.communication_date or communication.creation,
|
||||||
"creation": communication.creation,
|
"creation": communication.creation,
|
||||||
"data": {
|
"data": {
|
||||||
"subject": communication.subject,
|
"subject": communication.subject,
|
||||||
|
|||||||
32
crm/api/assignment_rule.py
Normal file
32
crm/api/assignment_rule.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_assignment_rules_list():
|
||||||
|
assignment_rules = []
|
||||||
|
for docname in frappe.get_all(
|
||||||
|
"Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]}
|
||||||
|
):
|
||||||
|
doc = frappe.get_value(
|
||||||
|
"Assignment Rule",
|
||||||
|
docname,
|
||||||
|
fieldname=[
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"disabled",
|
||||||
|
"priority",
|
||||||
|
],
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name}))
|
||||||
|
assignment_rules.append({**doc, "users_exists": users_exists})
|
||||||
|
return assignment_rules
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def duplicate_assignment_rule(docname, new_name):
|
||||||
|
doc = frappe.get_doc("Assignment Rule", docname)
|
||||||
|
doc.name = new_name
|
||||||
|
doc.assignment_rule_name = new_name
|
||||||
|
doc.insert()
|
||||||
|
return doc
|
||||||
@ -14,32 +14,16 @@ def update_deals_email_mobile_no(doc):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for linked_deal in linked_deals:
|
for linked_deal in linked_deals:
|
||||||
deal = frappe.get_cached_doc("CRM Deal", linked_deal.parent)
|
deal = frappe.db.get_values("CRM Deal", linked_deal.parent, ["email", "mobile_no"], as_dict=True)[0]
|
||||||
if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no:
|
if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no:
|
||||||
deal.email = doc.email_id
|
frappe.db.set_value(
|
||||||
deal.mobile_no = doc.mobile_no
|
"CRM Deal",
|
||||||
deal.save(ignore_permissions=True)
|
linked_deal.parent,
|
||||||
|
{
|
||||||
|
"email": doc.email_id,
|
||||||
@frappe.whitelist()
|
"mobile_no": doc.mobile_no,
|
||||||
def get_contact(name):
|
},
|
||||||
Contact = frappe.qb.DocType("Contact")
|
)
|
||||||
|
|
||||||
query = frappe.qb.from_(Contact).select("*").where(Contact.name == name).limit(1)
|
|
||||||
|
|
||||||
contact = query.run(as_dict=True)
|
|
||||||
if not len(contact):
|
|
||||||
frappe.throw(_("Contact not found"), frappe.DoesNotExistError)
|
|
||||||
contact = contact.pop()
|
|
||||||
|
|
||||||
contact["doctype"] = "Contact"
|
|
||||||
contact["email_ids"] = frappe.get_all(
|
|
||||||
"Contact Email", filters={"parent": name}, fields=["name", "email_id", "is_primary"]
|
|
||||||
)
|
|
||||||
contact["phone_nos"] = frappe.get_all(
|
|
||||||
"Contact Phone", filters={"parent": name}, fields=["name", "phone", "is_primary_mobile_no"]
|
|
||||||
)
|
|
||||||
return contact
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|||||||
1142
crm/api/dashboard.py
Normal file
1142
crm/api/dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
216
crm/api/doc.py
216
crm/api/doc.py
@ -3,6 +3,7 @@ import json
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||||
|
from frappe.desk.form.assign_to import set_status
|
||||||
from frappe.model import no_value_fields
|
from frappe.model import no_value_fields
|
||||||
from frappe.model.document import get_controller
|
from frappe.model.document import get_controller
|
||||||
from frappe.utils import make_filter_tuple
|
from frappe.utils import make_filter_tuple
|
||||||
@ -10,6 +11,7 @@ from pypika import Criterion
|
|||||||
|
|
||||||
from crm.api.views import get_views
|
from crm.api.views import get_views
|
||||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||||
|
from crm.utils import get_dynamic_linked_docs, get_linked_docs
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@ -418,16 +420,23 @@ def get_data(
|
|||||||
rows.append(field)
|
rows.append(field)
|
||||||
|
|
||||||
for kc in kanban_columns:
|
for kc in kanban_columns:
|
||||||
column_filters = {column_field: kc.get("name")}
|
# Start with base filters
|
||||||
|
column_filters = []
|
||||||
|
|
||||||
|
# Convert and add the main filters first
|
||||||
|
if filters:
|
||||||
|
base_filters = convert_filter_to_tuple(doctype, filters)
|
||||||
|
column_filters.extend(base_filters)
|
||||||
|
|
||||||
|
# Add the column-specific filter
|
||||||
|
if column_field and kc.get("name"):
|
||||||
|
column_filters.append([doctype, column_field, "=", kc.get("name")])
|
||||||
|
|
||||||
order = kc.get("order")
|
order = kc.get("order")
|
||||||
if (column_field in filters and filters.get(column_field) != kc.get("name")) or kc.get("delete"):
|
if kc.get("delete"):
|
||||||
column_data = []
|
column_data = []
|
||||||
else:
|
else:
|
||||||
column_filters.update(filters.copy())
|
page_length = kc.get("page_length", 20)
|
||||||
page_length = 20
|
|
||||||
|
|
||||||
if kc.get("page_length"):
|
|
||||||
page_length = kc.get("page_length")
|
|
||||||
|
|
||||||
if order:
|
if order:
|
||||||
column_data = get_records_based_on_order(
|
column_data = get_records_based_on_order(
|
||||||
@ -437,26 +446,20 @@ def get_data(
|
|||||||
column_data = frappe.get_list(
|
column_data = frappe.get_list(
|
||||||
doctype,
|
doctype,
|
||||||
fields=rows,
|
fields=rows,
|
||||||
filters=convert_filter_to_tuple(doctype, column_filters),
|
filters=column_filters,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
page_length=page_length,
|
page_length=page_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
new_filters = filters.copy()
|
|
||||||
new_filters.update({column_field: kc.get("name")})
|
|
||||||
|
|
||||||
all_count = frappe.get_list(
|
all_count = frappe.get_list(
|
||||||
doctype,
|
doctype,
|
||||||
filters=convert_filter_to_tuple(doctype, new_filters),
|
filters=column_filters,
|
||||||
fields="count(*) as total_count",
|
fields="count(*) as total_count",
|
||||||
)[0].total_count
|
)[0].total_count
|
||||||
|
|
||||||
kc["all_count"] = all_count
|
kc["all_count"] = all_count
|
||||||
kc["count"] = len(column_data)
|
kc["count"] = len(column_data)
|
||||||
|
|
||||||
for d in column_data:
|
|
||||||
getCounts(d, doctype)
|
|
||||||
|
|
||||||
if order:
|
if order:
|
||||||
column_data = sorted(
|
column_data = sorted(
|
||||||
column_data,
|
column_data,
|
||||||
@ -658,6 +661,25 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False, only_re
|
|||||||
return fields_meta
|
return fields_meta
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
|
||||||
|
assignees = frappe.parse_json(assignees)
|
||||||
|
|
||||||
|
if not assignees:
|
||||||
|
return
|
||||||
|
|
||||||
|
for assign_to in assignees:
|
||||||
|
set_status(
|
||||||
|
doctype,
|
||||||
|
name,
|
||||||
|
todo=None,
|
||||||
|
assign_to=assign_to,
|
||||||
|
status="Cancelled",
|
||||||
|
ignore_permissions=ignore_permissions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
def get_assigned_users(doctype, name, default_assigned_to=None):
|
def get_assigned_users(doctype, name, default_assigned_to=None):
|
||||||
assigned_users = frappe.get_all(
|
assigned_users = frappe.get_all(
|
||||||
"ToDo",
|
"ToDo",
|
||||||
@ -725,3 +747,167 @@ def getCounts(d, doctype):
|
|||||||
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
|
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
|
||||||
)
|
)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_linked_docs_of_document(doctype, docname):
|
||||||
|
try:
|
||||||
|
doc = frappe.get_doc(doctype, docname)
|
||||||
|
except frappe.DoesNotExistError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
linked_docs = get_linked_docs(doc)
|
||||||
|
dynamic_linked_docs = get_dynamic_linked_docs(doc)
|
||||||
|
|
||||||
|
linked_docs.extend(dynamic_linked_docs)
|
||||||
|
linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values())
|
||||||
|
|
||||||
|
docs_data = []
|
||||||
|
for doc in linked_docs:
|
||||||
|
if not doc.get("reference_doctype") or not doc.get("reference_docname"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
||||||
|
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = data.get("title")
|
||||||
|
if data.doctype == "CRM Call Log":
|
||||||
|
title = f"Call from {data.get('from')} to {data.get('to')}"
|
||||||
|
|
||||||
|
if data.doctype == "CRM Deal":
|
||||||
|
title = data.get("organization")
|
||||||
|
|
||||||
|
if data.doctype == "CRM Notification":
|
||||||
|
title = data.get("message")
|
||||||
|
|
||||||
|
docs_data.append(
|
||||||
|
{
|
||||||
|
"doc": data.doctype,
|
||||||
|
"title": title or data.get("name"),
|
||||||
|
"reference_docname": doc["reference_docname"],
|
||||||
|
"reference_doctype": doc["reference_doctype"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return docs_data
|
||||||
|
|
||||||
|
|
||||||
|
def remove_doc_link(doctype, docname):
|
||||||
|
if not doctype or not docname:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||||
|
if doctype == "CRM Notification":
|
||||||
|
delete_notification_type = {
|
||||||
|
"notification_type_doctype": "",
|
||||||
|
"notification_type_doc": "",
|
||||||
|
}
|
||||||
|
delete_references = {
|
||||||
|
"reference_doctype": "",
|
||||||
|
"reference_name": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"):
|
||||||
|
delete_references.update(delete_notification_type)
|
||||||
|
|
||||||
|
linked_doc_data.update(delete_references)
|
||||||
|
else:
|
||||||
|
linked_doc_data.update(
|
||||||
|
{
|
||||||
|
"reference_doctype": "",
|
||||||
|
"reference_docname": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
linked_doc_data.save(ignore_permissions=True)
|
||||||
|
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def remove_contact_link(doctype, docname):
|
||||||
|
if not doctype or not docname:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||||
|
linked_doc_data.update(
|
||||||
|
{
|
||||||
|
"contact": None,
|
||||||
|
"contacts": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
linked_doc_data.save(ignore_permissions=True)
|
||||||
|
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
||||||
|
if isinstance(items, str):
|
||||||
|
items = frappe.parse_json(items)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if not item.get("doctype") or not item.get("docname"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if remove_contact:
|
||||||
|
remove_contact_link(item["doctype"], item["docname"])
|
||||||
|
else:
|
||||||
|
remove_doc_link(item["doctype"], item["docname"])
|
||||||
|
|
||||||
|
if delete:
|
||||||
|
frappe.delete_doc(item["doctype"], item["docname"])
|
||||||
|
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||||
|
# Skip if document doesn't exist or has validation errors
|
||||||
|
continue
|
||||||
|
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def delete_bulk_docs(doctype, items, delete_linked=False):
|
||||||
|
from frappe.desk.reportview import delete_bulk
|
||||||
|
|
||||||
|
if not doctype:
|
||||||
|
frappe.throw("Doctype is required")
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
frappe.throw("Items are required")
|
||||||
|
|
||||||
|
items = frappe.parse_json(items)
|
||||||
|
if not isinstance(items, list):
|
||||||
|
frappe.throw("Items must be a list")
|
||||||
|
|
||||||
|
for doc in items:
|
||||||
|
try:
|
||||||
|
if not frappe.db.exists(doctype, doc):
|
||||||
|
frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error")
|
||||||
|
continue
|
||||||
|
|
||||||
|
linked_docs = get_linked_docs_of_document(doctype, doc)
|
||||||
|
for linked_doc in linked_docs:
|
||||||
|
if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
remove_linked_doc_reference(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"doctype": linked_doc["reference_doctype"],
|
||||||
|
"docname": linked_doc["reference_docname"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
remove_contact=doctype == "Contact",
|
||||||
|
delete=delete_linked,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(
|
||||||
|
f"Error processing linked docs for {doctype} {doc}: {str(e)}", "Bulk Delete Error"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(items) > 10:
|
||||||
|
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
|
||||||
|
else:
|
||||||
|
delete_bulk(doctype, items)
|
||||||
|
return "success"
|
||||||
|
|||||||
@ -23,11 +23,32 @@ def get_users():
|
|||||||
if frappe.session.user == user.name:
|
if frappe.session.user == user.name:
|
||||||
user.session_user = True
|
user.session_user = True
|
||||||
|
|
||||||
user.is_manager = "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator"
|
user.roles = frappe.get_roles(user.name)
|
||||||
|
|
||||||
user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
user.role = ""
|
||||||
|
|
||||||
return users
|
if "System Manager" in user.roles:
|
||||||
|
user.role = "System Manager"
|
||||||
|
elif "Sales Manager" in user.roles:
|
||||||
|
user.role = "Sales Manager"
|
||||||
|
elif "Sales User" in user.roles:
|
||||||
|
user.role = "Sales User"
|
||||||
|
elif "Guest" in user.roles:
|
||||||
|
user.role = "Guest"
|
||||||
|
|
||||||
|
if frappe.session.user == user.name:
|
||||||
|
user.session_user = True
|
||||||
|
|
||||||
|
user.is_telephony_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||||
|
|
||||||
|
crm_users = []
|
||||||
|
|
||||||
|
# crm users are users with role Sales User or Sales Manager
|
||||||
|
for user in users:
|
||||||
|
if "Sales User" in user.roles or "Sales Manager" in user.roles:
|
||||||
|
crm_users.append(user)
|
||||||
|
|
||||||
|
return users, crm_users
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
|
|||||||
99
crm/api/settings.py
Normal file
99
crm/api/settings.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def create_email_account(data):
|
||||||
|
service = data.get("service")
|
||||||
|
service_config = email_service_config.get(service)
|
||||||
|
if not service_config:
|
||||||
|
return "Service not supported"
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Email Account",
|
||||||
|
"email_id": data.get("email_id"),
|
||||||
|
"email_account_name": data.get("email_account_name"),
|
||||||
|
"service": service,
|
||||||
|
"enable_incoming": data.get("enable_incoming"),
|
||||||
|
"enable_outgoing": data.get("enable_outgoing"),
|
||||||
|
"default_incoming": data.get("default_incoming"),
|
||||||
|
"default_outgoing": data.get("default_outgoing"),
|
||||||
|
"email_sync_option": "ALL",
|
||||||
|
"initial_sync_count": 100,
|
||||||
|
"create_contact": 1,
|
||||||
|
"track_email_status": 1,
|
||||||
|
"use_tls": 1,
|
||||||
|
"use_imap": 1,
|
||||||
|
"smtp_port": 587,
|
||||||
|
**service_config,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if service == "Frappe Mail":
|
||||||
|
email_doc.api_key = data.get("api_key")
|
||||||
|
email_doc.api_secret = data.get("api_secret")
|
||||||
|
email_doc.frappe_mail_site = data.get("frappe_mail_site")
|
||||||
|
email_doc.append_to = "CRM Lead"
|
||||||
|
else:
|
||||||
|
email_doc.append("imap_folder", {"append_to": "CRM Lead", "folder_name": "INBOX"})
|
||||||
|
email_doc.password = data.get("password")
|
||||||
|
# validate whether the credentials are correct
|
||||||
|
email_doc.get_incoming_server()
|
||||||
|
|
||||||
|
# if correct credentials, save the email account
|
||||||
|
email_doc.save()
|
||||||
|
except Exception as e:
|
||||||
|
frappe.throw(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
email_service_config = {
|
||||||
|
"Frappe Mail": {
|
||||||
|
"domain": None,
|
||||||
|
"password": None,
|
||||||
|
"awaiting_password": 0,
|
||||||
|
"ascii_encode_password": 0,
|
||||||
|
"login_id_is_different": 0,
|
||||||
|
"login_id": None,
|
||||||
|
"use_imap": 0,
|
||||||
|
"use_ssl": 0,
|
||||||
|
"validate_ssl_certificate": 0,
|
||||||
|
"use_starttls": 0,
|
||||||
|
"email_server": None,
|
||||||
|
"incoming_port": 0,
|
||||||
|
"always_use_account_email_id_as_sender": 1,
|
||||||
|
"use_tls": 0,
|
||||||
|
"use_ssl_for_outgoing": 0,
|
||||||
|
"smtp_server": None,
|
||||||
|
"smtp_port": None,
|
||||||
|
"no_smtp_authentication": 0,
|
||||||
|
},
|
||||||
|
"GMail": {
|
||||||
|
"email_server": "imap.gmail.com",
|
||||||
|
"use_ssl": 1,
|
||||||
|
"smtp_server": "smtp.gmail.com",
|
||||||
|
},
|
||||||
|
"Outlook": {
|
||||||
|
"email_server": "imap-mail.outlook.com",
|
||||||
|
"use_ssl": 1,
|
||||||
|
"smtp_server": "smtp-mail.outlook.com",
|
||||||
|
},
|
||||||
|
"Sendgrid": {
|
||||||
|
"smtp_server": "smtp.sendgrid.net",
|
||||||
|
"smtp_port": 587,
|
||||||
|
},
|
||||||
|
"SparkPost": {
|
||||||
|
"smtp_server": "smtp.sparkpostmail.com",
|
||||||
|
},
|
||||||
|
"Yahoo": {
|
||||||
|
"email_server": "imap.mail.yahoo.com",
|
||||||
|
"use_ssl": 1,
|
||||||
|
"smtp_server": "smtp.mail.yahoo.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
},
|
||||||
|
"Yandex": {
|
||||||
|
"email_server": "imap.yandex.com",
|
||||||
|
"use_ssl": 1,
|
||||||
|
"smtp_server": "smtp.yandex.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
},
|
||||||
|
}
|
||||||
137
crm/api/todo.py
137
crm/api/todo.py
@ -1,90 +1,79 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
||||||
|
|
||||||
|
|
||||||
def after_insert(doc, method):
|
def after_insert(doc, method):
|
||||||
if (
|
if doc.reference_type in ["CRM Lead", "CRM Deal"] and doc.reference_name and doc.allocated_to:
|
||||||
doc.reference_type in ["CRM Lead", "CRM Deal"]
|
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
||||||
and doc.reference_name
|
owner = frappe.db.get_value(doc.reference_type, doc.reference_name, fieldname)
|
||||||
and doc.allocated_to
|
if not owner:
|
||||||
):
|
frappe.db.set_value(
|
||||||
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to, update_modified=False
|
||||||
lead_owner = frappe.db.get_value(
|
)
|
||||||
doc.reference_type, doc.reference_name, fieldname
|
|
||||||
)
|
|
||||||
if not lead_owner:
|
|
||||||
frappe.db.set_value(
|
|
||||||
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to:
|
||||||
doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
notify_assigned_user(doc)
|
||||||
and doc.reference_name
|
|
||||||
and doc.allocated_to
|
|
||||||
):
|
|
||||||
notify_assigned_user(doc)
|
|
||||||
|
|
||||||
|
|
||||||
def on_update(doc, method):
|
def on_update(doc, method):
|
||||||
if (
|
if (
|
||||||
doc.has_value_changed("status")
|
doc.has_value_changed("status")
|
||||||
and doc.status == "Cancelled"
|
and doc.status == "Cancelled"
|
||||||
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||||
and doc.reference_name
|
and doc.reference_name
|
||||||
and doc.allocated_to
|
and doc.allocated_to
|
||||||
):
|
):
|
||||||
notify_assigned_user(doc, is_cancelled=True)
|
notify_assigned_user(doc, is_cancelled=True)
|
||||||
|
|
||||||
|
|
||||||
def notify_assigned_user(doc, is_cancelled=False):
|
def notify_assigned_user(doc, is_cancelled=False):
|
||||||
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||||
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
||||||
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
_("Your assignment on {0} {1} has been removed by {2}").format(
|
_("Your assignment on {0} {1} has been removed by {2}").format(
|
||||||
doc.reference_type, doc.reference_name, owner
|
doc.reference_type, doc.reference_name, owner
|
||||||
)
|
)
|
||||||
if is_cancelled
|
if is_cancelled
|
||||||
else _("{0} assigned a {1} {2} to you").format(
|
else _("{0} assigned a {1} {2} to you").format(owner, doc.reference_type, doc.reference_name)
|
||||||
owner, doc.reference_type, doc.reference_name
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
||||||
|
|
||||||
notify_user(
|
notify_user(
|
||||||
{
|
{
|
||||||
"owner": frappe.session.user,
|
"owner": frappe.session.user,
|
||||||
"assigned_to": doc.allocated_to,
|
"assigned_to": doc.allocated_to,
|
||||||
"notification_type": "Assignment",
|
"notification_type": "Assignment",
|
||||||
"message": message,
|
"message": message,
|
||||||
"notification_text": notification_text,
|
"notification_text": notification_text,
|
||||||
"reference_doctype": doc.reference_type,
|
"reference_doctype": doc.reference_type,
|
||||||
"reference_docname": doc.reference_name,
|
"reference_docname": doc.reference_name,
|
||||||
"redirect_to_doctype": redirect_to_doctype,
|
"redirect_to_doctype": redirect_to_doctype,
|
||||||
"redirect_to_docname": redirect_to_name,
|
"redirect_to_docname": redirect_to_name,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||||
name = doc.reference_name
|
name = doc.reference_name
|
||||||
doctype = doc.reference_type
|
doctype = doc.reference_type
|
||||||
|
|
||||||
if doctype.startswith("CRM "):
|
if doctype.startswith("CRM "):
|
||||||
doctype = doctype[4:].lower()
|
doctype = doctype[4:].lower()
|
||||||
|
|
||||||
if doctype in ["lead", "deal"]:
|
if doctype in ["lead", "deal"]:
|
||||||
name = (
|
name = (
|
||||||
reference_doc.lead_name or name
|
reference_doc.lead_name or name
|
||||||
if doctype == "lead"
|
if doctype == "lead"
|
||||||
else reference_doc.organization or reference_doc.lead_name or name
|
else reference_doc.organization or reference_doc.lead_name or name
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_cancelled:
|
if is_cancelled:
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
||||||
doctype,
|
doctype,
|
||||||
@ -94,7 +83,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||||
<span>{ _('assigned a {0} {1} to you').format(
|
<span>{ _('assigned a {0} {1} to you').format(
|
||||||
@ -104,9 +93,9 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if doctype == "task":
|
if doctype == "task":
|
||||||
if is_cancelled:
|
if is_cancelled:
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
||||||
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
|
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
|
||||||
@ -114,7 +103,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
) }</span>
|
) }</span>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
return f"""
|
return f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||||
<span>{ _('assigned a new task {0} to you').format(
|
<span>{ _('assigned a new task {0} to you').format(
|
||||||
@ -125,8 +114,8 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
|||||||
|
|
||||||
|
|
||||||
def get_redirect_to_doc(doc):
|
def get_redirect_to_doc(doc):
|
||||||
if doc.reference_type == "CRM Task":
|
if doc.reference_type == "CRM Task":
|
||||||
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||||
return reference_doc.reference_doctype, reference_doc.reference_docname
|
return reference_doc.reference_doctype, reference_doc.reference_docname
|
||||||
|
|
||||||
return doc.reference_type, doc.reference_name
|
return doc.reference_type, doc.reference_name
|
||||||
|
|||||||
84
crm/api/user.py
Normal file
84
crm/api/user.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def add_existing_users(users, role="Sales User"):
|
||||||
|
"""
|
||||||
|
Add existing users to the CRM by assigning them a role (Sales User or Sales Manager).
|
||||||
|
:param users: List of user names to be added
|
||||||
|
"""
|
||||||
|
frappe.only_for(["System Manager", "Sales Manager"])
|
||||||
|
users = frappe.parse_json(users)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
add_user(user, role)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def update_user_role(user, new_role):
|
||||||
|
"""
|
||||||
|
Update the role of the user to Sales Manager, Sales User, or System Manager.
|
||||||
|
:param user: The name of the user
|
||||||
|
:param new_role: The new role to assign (Sales Manager or Sales User)
|
||||||
|
"""
|
||||||
|
|
||||||
|
frappe.only_for(["System Manager", "Sales Manager"])
|
||||||
|
|
||||||
|
if new_role not in ["System Manager", "Sales Manager", "Sales User"]:
|
||||||
|
frappe.throw("Cannot assign this role")
|
||||||
|
|
||||||
|
user_doc = frappe.get_doc("User", user)
|
||||||
|
|
||||||
|
if new_role == "System Manager":
|
||||||
|
user_doc.append_roles("System Manager", "Sales Manager", "Sales User")
|
||||||
|
user_doc.set("block_modules", [])
|
||||||
|
if new_role == "Sales Manager":
|
||||||
|
user_doc.append_roles("Sales Manager", "Sales User")
|
||||||
|
user_doc.remove_roles("System Manager")
|
||||||
|
if new_role == "Sales User":
|
||||||
|
user_doc.append_roles("Sales User")
|
||||||
|
user_doc.remove_roles("Sales Manager", "System Manager")
|
||||||
|
update_module_in_user(user_doc, "FCRM")
|
||||||
|
|
||||||
|
user_doc.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def add_user(user, role):
|
||||||
|
"""
|
||||||
|
Add a user means adding role (Sales User or/and Sales Manager) to the user.
|
||||||
|
:param user: The name of the user to be added
|
||||||
|
:param role: The role to be assigned (Sales User or Sales Manager)
|
||||||
|
"""
|
||||||
|
update_user_role(user, role)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def remove_user(user):
|
||||||
|
"""
|
||||||
|
Remove a user means removing Sales User & Sales Manager roles from the user.
|
||||||
|
:param user: The name of the user to be removed
|
||||||
|
"""
|
||||||
|
frappe.only_for(["System Manager", "Sales Manager"])
|
||||||
|
|
||||||
|
user_doc = frappe.get_doc("User", user)
|
||||||
|
roles = [d.role for d in user_doc.roles]
|
||||||
|
|
||||||
|
if "Sales User" in roles:
|
||||||
|
user_doc.remove_roles("Sales User")
|
||||||
|
if "Sales Manager" in roles:
|
||||||
|
user_doc.remove_roles("Sales Manager")
|
||||||
|
|
||||||
|
user_doc.save(ignore_permissions=True)
|
||||||
|
frappe.msgprint(f"User {user} has been removed from CRM roles.")
|
||||||
|
|
||||||
|
|
||||||
|
def update_module_in_user(user, module):
|
||||||
|
block_modules = frappe.get_all(
|
||||||
|
"Module Def",
|
||||||
|
fields=["name as module"],
|
||||||
|
filters={"name": ["!=", module]},
|
||||||
|
)
|
||||||
|
|
||||||
|
if block_modules:
|
||||||
|
user.set("block_modules", block_modules)
|
||||||
@ -10,8 +10,15 @@ from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
|||||||
def validate(doc, method):
|
def validate(doc, method):
|
||||||
if doc.type == "Incoming" and doc.get("from"):
|
if doc.type == "Incoming" and doc.get("from"):
|
||||||
name, doctype = get_lead_or_deal_from_number(doc.get("from"))
|
name, doctype = get_lead_or_deal_from_number(doc.get("from"))
|
||||||
doc.reference_doctype = doctype
|
if name != None:
|
||||||
doc.reference_name = name
|
doc.reference_doctype = doctype
|
||||||
|
doc.reference_name = name
|
||||||
|
|
||||||
|
if doc.type == "Outgoing" and doc.get("to"):
|
||||||
|
name, doctype = get_lead_or_deal_from_number(doc.get("to"))
|
||||||
|
if name != None:
|
||||||
|
doc.reference_doctype = doctype
|
||||||
|
doc.reference_name = name
|
||||||
|
|
||||||
|
|
||||||
def on_update(doc, method):
|
def on_update(doc, method):
|
||||||
@ -29,7 +36,7 @@ def on_update(doc, method):
|
|||||||
def notify_agent(doc):
|
def notify_agent(doc):
|
||||||
if doc.type == "Incoming":
|
if doc.type == "Incoming":
|
||||||
doctype = doc.reference_doctype
|
doctype = doc.reference_doctype
|
||||||
if doctype.startswith("CRM "):
|
if doctype and doctype.startswith("CRM "):
|
||||||
doctype = doctype[4:].lower()
|
doctype = doctype[4:].lower()
|
||||||
notification_text = f"""
|
notification_text = f"""
|
||||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||||
@ -335,5 +342,5 @@ def get_from_name(message):
|
|||||||
else:
|
else:
|
||||||
from_name = doc.get("lead_name")
|
from_name = doc.get("lead_name")
|
||||||
else:
|
else:
|
||||||
from_name = doc.get("first_name") + " " + doc.get("last_name")
|
from_name = " ".join(filter(None, [doc.get("first_name"), doc.get("last_name")]))
|
||||||
return from_name
|
return from_name
|
||||||
|
|||||||
@ -41,13 +41,15 @@
|
|||||||
"fieldname": "from",
|
"fieldname": "from",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "From"
|
"label": "From",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "status",
|
"fieldname": "status",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled"
|
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "start_time",
|
"fieldname": "start_time",
|
||||||
@ -69,13 +71,15 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Type",
|
"label": "Type",
|
||||||
"options": "Incoming\nOutgoing"
|
"options": "Incoming\nOutgoing",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "to",
|
"fieldname": "to",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "To"
|
"label": "To",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Call duration in seconds",
|
"description": "Call duration in seconds",
|
||||||
@ -153,7 +157,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-22 17:57:59.289548",
|
"modified": "2025-04-01 16:01:54.479309",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Call Log",
|
"name": "CRM Call Log",
|
||||||
|
|||||||
@ -190,11 +190,20 @@ def get_call_log(name):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def create_lead_from_call_log(call_log):
|
def create_lead_from_call_log(call_log, lead_details=None):
|
||||||
lead = frappe.new_doc("CRM Lead")
|
lead = frappe.new_doc("CRM Lead")
|
||||||
lead.first_name = "Lead from call " + call_log.get("from")
|
lead_details = frappe.parse_json(lead_details or "{}")
|
||||||
lead.mobile_no = call_log.get("from")
|
|
||||||
lead.lead_owner = frappe.session.user
|
if not lead_details.get("lead_owner"):
|
||||||
|
lead_details["lead_owner"] = frappe.session.user
|
||||||
|
if not lead_details.get("mobile_no"):
|
||||||
|
lead_details["mobile_no"] = call_log.get("from") or ""
|
||||||
|
if not lead_details.get("first_name"):
|
||||||
|
lead_details["first_name"] = "Lead from call " + (
|
||||||
|
lead_details.get("mobile_no") or call_log.get("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
lead.update(lead_details)
|
||||||
lead.save(ignore_permissions=True)
|
lead.save(ignore_permissions=True)
|
||||||
|
|
||||||
# link call log with lead
|
# link call log with lead
|
||||||
|
|||||||
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.js
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("CRM Dashboard", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
105
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
105
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "field:title",
|
||||||
|
"creation": "2025-07-14 12:19:49.725022",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"title",
|
||||||
|
"private",
|
||||||
|
"column_break_exbw",
|
||||||
|
"user",
|
||||||
|
"section_break_hfza",
|
||||||
|
"layout"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_exbw",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_hfza",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "[]",
|
||||||
|
"fieldname": "layout",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Layout",
|
||||||
|
"options": "JSON"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "title",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Name",
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "private",
|
||||||
|
"fieldname": "user",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "User",
|
||||||
|
"mandatory_depends_on": "private",
|
||||||
|
"options": "User"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "private",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Private"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-07-14 12:36:10.831351",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "CRM Dashboard",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales User",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"title_field": "title"
|
||||||
|
}
|
||||||
34
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
34
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class CRMDashboard(Document):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def default_manager_dashboard_layout():
|
||||||
|
"""
|
||||||
|
Returns the default layout for the CRM Manager Dashboard.
|
||||||
|
"""
|
||||||
|
return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]'
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_manager_dashboard(force=False):
|
||||||
|
"""
|
||||||
|
Creates the default CRM Manager Dashboard if it does not exist.
|
||||||
|
"""
|
||||||
|
if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"):
|
||||||
|
doc = frappe.new_doc("CRM Dashboard")
|
||||||
|
doc.title = "Manager Dashboard"
|
||||||
|
doc.layout = default_manager_dashboard_layout()
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
else:
|
||||||
|
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
|
||||||
|
if force:
|
||||||
|
doc.layout = default_manager_dashboard_layout()
|
||||||
|
doc.save(ignore_permissions=True)
|
||||||
|
return doc.layout
|
||||||
30
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
30
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
class UnitTestCRMDashboard(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for CRMDashboard.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestCRMDashboard(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for CRMDashboard.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@ -1,18 +1,5 @@
|
|||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
|
||||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_deal(name):
|
|
||||||
deal = frappe.get_doc("CRM Deal", name).as_dict()
|
|
||||||
|
|
||||||
deal["fields_meta"] = get_fields_meta("CRM Deal")
|
|
||||||
deal["_form_script"] = get_form_script("CRM Deal")
|
|
||||||
deal["_assign"] = get_assigned_users("CRM Deal", deal.name)
|
|
||||||
return deal
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_deal_contacts(name):
|
def get_deal_contacts(name):
|
||||||
@ -30,24 +17,12 @@ def get_deal_contacts(name):
|
|||||||
is_primary = contact.is_primary
|
is_primary = contact.is_primary
|
||||||
contact = frappe.get_doc("Contact", contact.contact).as_dict()
|
contact = frappe.get_doc("Contact", contact.contact).as_dict()
|
||||||
|
|
||||||
def get_primary_email(contact):
|
|
||||||
for email in contact.email_ids:
|
|
||||||
if email.is_primary:
|
|
||||||
return email.email_id
|
|
||||||
return contact.email_ids[0].email_id if contact.email_ids else ""
|
|
||||||
|
|
||||||
def get_primary_mobile_no(contact):
|
|
||||||
for phone in contact.phone_nos:
|
|
||||||
if phone.is_primary:
|
|
||||||
return phone.phone
|
|
||||||
return contact.phone_nos[0].phone if contact.phone_nos else ""
|
|
||||||
|
|
||||||
_contact = {
|
_contact = {
|
||||||
"name": contact.name,
|
"name": contact.name,
|
||||||
"image": contact.image,
|
"image": contact.image,
|
||||||
"full_name": contact.full_name,
|
"full_name": contact.full_name,
|
||||||
"email": get_primary_email(contact),
|
"email": contact.email_id,
|
||||||
"mobile_no": get_primary_mobile_no(contact),
|
"mobile_no": contact.mobile_no,
|
||||||
"is_primary": is_primary,
|
"is_primary": is_primary,
|
||||||
}
|
}
|
||||||
deal_contacts.append(_contact)
|
deal_contacts.append(_contact)
|
||||||
|
|||||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Deal", {
|
|||||||
refresh(frm) {
|
refresh(frm) {
|
||||||
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
|
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
|
||||||
},
|
},
|
||||||
|
update_total: function (frm) {
|
||||||
|
let total = 0;
|
||||||
|
let total_qty = 0;
|
||||||
|
let net_total = 0;
|
||||||
|
frm.doc.products.forEach((d) => {
|
||||||
|
total += d.amount;
|
||||||
|
total_qty += d.qty;
|
||||||
|
net_total += d.net_amount;
|
||||||
|
});
|
||||||
|
|
||||||
|
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||||
|
frappe.model.set_value(
|
||||||
|
frm.doctype,
|
||||||
|
frm.docname,
|
||||||
|
"net_total",
|
||||||
|
net_total || total
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frappe.ui.form.on("CRM Products", {
|
||||||
|
products_add: function (frm, cdt, cdn) {
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
products_remove: function (frm, cdt, cdn) {
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
product_code: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||||
|
},
|
||||||
|
rate: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
if (d.rate && d.qty) {
|
||||||
|
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||||
|
}
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
qty: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
if (d.rate && d.qty) {
|
||||||
|
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||||
|
}
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
discount_percentage: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
if (d.discount_percentage && d.amount) {
|
||||||
|
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||||
|
frappe.model.set_value(
|
||||||
|
cdt,
|
||||||
|
cdn,
|
||||||
|
"discount_amount",
|
||||||
|
discount_amount
|
||||||
|
);
|
||||||
|
frappe.model.set_value(
|
||||||
|
cdt,
|
||||||
|
cdn,
|
||||||
|
"net_amount",
|
||||||
|
d.amount - discount_amount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frm.trigger("update_total");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,11 +11,18 @@
|
|||||||
"naming_series",
|
"naming_series",
|
||||||
"organization",
|
"organization",
|
||||||
"next_step",
|
"next_step",
|
||||||
"probability",
|
|
||||||
"column_break_ijan",
|
"column_break_ijan",
|
||||||
"status",
|
"status",
|
||||||
"close_date",
|
|
||||||
"deal_owner",
|
"deal_owner",
|
||||||
|
"lost_reason",
|
||||||
|
"lost_notes",
|
||||||
|
"section_break_jgpm",
|
||||||
|
"probability",
|
||||||
|
"expected_deal_value",
|
||||||
|
"deal_value",
|
||||||
|
"column_break_kpxa",
|
||||||
|
"expected_closure_date",
|
||||||
|
"closed_date",
|
||||||
"contacts_tab",
|
"contacts_tab",
|
||||||
"contacts",
|
"contacts",
|
||||||
"contact",
|
"contact",
|
||||||
@ -32,6 +39,7 @@
|
|||||||
"column_break_xbyf",
|
"column_break_xbyf",
|
||||||
"territory",
|
"territory",
|
||||||
"currency",
|
"currency",
|
||||||
|
"exchange_rate",
|
||||||
"annual_revenue",
|
"annual_revenue",
|
||||||
"industry",
|
"industry",
|
||||||
"person_section",
|
"person_section",
|
||||||
@ -43,6 +51,12 @@
|
|||||||
"mobile_no",
|
"mobile_no",
|
||||||
"phone",
|
"phone",
|
||||||
"gender",
|
"gender",
|
||||||
|
"products_tab",
|
||||||
|
"products",
|
||||||
|
"section_break_ccbj",
|
||||||
|
"total",
|
||||||
|
"column_break_udbq",
|
||||||
|
"net_total",
|
||||||
"sla_tab",
|
"sla_tab",
|
||||||
"sla",
|
"sla",
|
||||||
"sla_creation",
|
"sla_creation",
|
||||||
@ -82,11 +96,6 @@
|
|||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Website"
|
"label": "Website"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "close_date",
|
|
||||||
"fieldtype": "Date",
|
|
||||||
"label": "Close Date"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "next_step",
|
"fieldname": "next_step",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
@ -119,13 +128,13 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "email",
|
"fieldname": "email",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Email",
|
"label": "Primary Email",
|
||||||
"options": "Email"
|
"options": "Email"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "mobile_no",
|
"fieldname": "mobile_no",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Mobile No",
|
"label": "Primary Mobile No",
|
||||||
"options": "Phone"
|
"options": "Phone"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -239,7 +248,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "phone",
|
"fieldname": "phone",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Phone",
|
"label": "Primary Phone",
|
||||||
"options": "Phone"
|
"options": "Phone"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -334,11 +343,96 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Currency",
|
"label": "Currency",
|
||||||
"options": "Currency"
|
"options": "Currency"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "products_tab",
|
||||||
|
"fieldtype": "Tab Break",
|
||||||
|
"label": "Products"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "products",
|
||||||
|
"fieldtype": "Table",
|
||||||
|
"label": "Products",
|
||||||
|
"options": "CRM Products"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_ccbj",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_udbq",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "total",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Total",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Total after discount",
|
||||||
|
"fieldname": "net_total",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Net Total",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_jgpm",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "deal_value",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Deal Value",
|
||||||
|
"options": "currency"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_kpxa",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "lost_reason",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Lost Reason",
|
||||||
|
"mandatory_depends_on": "eval: doc.status == \"Lost\"",
|
||||||
|
"options": "CRM Lost Reason"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "lost_notes",
|
||||||
|
"fieldtype": "Text",
|
||||||
|
"label": "Lost Notes",
|
||||||
|
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||||
|
"fieldname": "exchange_rate",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Exchange Rate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "expected_deal_value",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Expected Deal Value",
|
||||||
|
"options": "currency"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "expected_closure_date",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Expected Closure Date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "closed_date",
|
||||||
|
"fieldtype": "Date",
|
||||||
|
"label": "Closed Date"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-12-11 14:31:41.058895",
|
"modified": "2025-08-26 12:12:56.324245",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal",
|
"name": "CRM Deal",
|
||||||
@ -370,10 +464,11 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "organization",
|
"title_field": "organization",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,8 @@ from frappe.desk.form.assign_to import add as assign
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
|
||||||
add_status_change_log,
|
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CRMDeal(Document):
|
class CRMDeal(Document):
|
||||||
@ -24,6 +23,11 @@ class CRMDeal(Document):
|
|||||||
self.assign_agent(self.deal_owner)
|
self.assign_agent(self.deal_owner)
|
||||||
if self.has_value_changed("status"):
|
if self.has_value_changed("status"):
|
||||||
add_status_change_log(self)
|
add_status_change_log(self)
|
||||||
|
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
||||||
|
self.closed_date = frappe.utils.nowdate()
|
||||||
|
self.validate_forecasting_fields()
|
||||||
|
self.validate_lost_reason()
|
||||||
|
self.update_exchange_rate()
|
||||||
|
|
||||||
def after_insert(self):
|
def after_insert(self):
|
||||||
if self.deal_owner:
|
if self.deal_owner:
|
||||||
@ -133,6 +137,60 @@ class CRMDeal(Document):
|
|||||||
if sla:
|
if sla:
|
||||||
sla.apply(self)
|
sla.apply(self)
|
||||||
|
|
||||||
|
def update_closed_date(self):
|
||||||
|
"""
|
||||||
|
Update the closed date based on the "Won" status.
|
||||||
|
"""
|
||||||
|
if self.status == "Won" and not self.closed_date:
|
||||||
|
self.closed_date = frappe.utils.nowdate()
|
||||||
|
|
||||||
|
def update_default_probability(self):
|
||||||
|
"""
|
||||||
|
Update the default probability based on the status.
|
||||||
|
"""
|
||||||
|
if not self.probability or self.probability == 0:
|
||||||
|
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||||
|
|
||||||
|
def update_expected_deal_value(self):
|
||||||
|
"""
|
||||||
|
Update the expected deal value based on the net total or total.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
frappe.db.get_single_value("FCRM Settings", "auto_update_expected_deal_value")
|
||||||
|
and (self.net_total or self.total)
|
||||||
|
and self.expected_deal_value
|
||||||
|
):
|
||||||
|
self.expected_deal_value = self.net_total or self.total
|
||||||
|
|
||||||
|
def validate_forecasting_fields(self):
|
||||||
|
self.update_closed_date()
|
||||||
|
self.update_default_probability()
|
||||||
|
self.update_expected_deal_value()
|
||||||
|
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
|
||||||
|
if not self.expected_deal_value or self.expected_deal_value == 0:
|
||||||
|
frappe.throw(_("Expected Deal Value is required."), frappe.MandatoryError)
|
||||||
|
if not self.expected_closure_date:
|
||||||
|
frappe.throw(_("Expected Closure Date is required."), frappe.MandatoryError)
|
||||||
|
|
||||||
|
def validate_lost_reason(self):
|
||||||
|
"""
|
||||||
|
Validate the lost reason if the status is set to "Lost".
|
||||||
|
"""
|
||||||
|
if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "Lost":
|
||||||
|
if not self.lost_reason:
|
||||||
|
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
|
||||||
|
elif self.lost_reason == "Other" and not self.lost_notes:
|
||||||
|
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
|
||||||
|
|
||||||
|
def update_exchange_rate(self):
|
||||||
|
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||||
|
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||||
|
exchange_rate = 1
|
||||||
|
if self.currency and self.currency != system_currency:
|
||||||
|
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
||||||
|
|
||||||
|
self.db_set("exchange_rate", exchange_rate)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_list_data():
|
def default_list_data():
|
||||||
columns = [
|
columns = [
|
||||||
|
|||||||
@ -7,8 +7,11 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"deal_status",
|
"deal_status",
|
||||||
"color",
|
"type",
|
||||||
"position"
|
"position",
|
||||||
|
"column_break_ojiu",
|
||||||
|
"probability",
|
||||||
|
"color"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -32,11 +35,30 @@
|
|||||||
"fieldtype": "Int",
|
"fieldtype": "Int",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Position"
|
"label": "Position"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "probability",
|
||||||
|
"fieldtype": "Percent",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Probability"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "Open",
|
||||||
|
"fieldname": "type",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Type",
|
||||||
|
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ojiu",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-01-19 21:56:44.552134",
|
"modified": "2025-07-11 16:03:28.077955",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal Status",
|
"name": "CRM Deal Status",
|
||||||
@ -68,7 +90,8 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,9 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
|||||||
if not tabs and type != "Required Fields":
|
if not tabs and type != "Required Fields":
|
||||||
tabs = get_default_layout(doctype)
|
tabs = get_default_layout(doctype)
|
||||||
|
|
||||||
has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
|
has_tabs = False
|
||||||
|
if isinstance(tabs, list) and len(tabs) > 0 and isinstance(tabs[0], dict):
|
||||||
|
has_tabs = any("sections" in tab for tab in tabs)
|
||||||
|
|
||||||
if not has_tabs:
|
if not has_tabs:
|
||||||
tabs = [{"name": "first_tab", "sections": tabs}]
|
tabs = [{"name": "first_tab", "sections": tabs}]
|
||||||
@ -45,9 +47,19 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
|||||||
fields = frappe.get_meta(doctype).fields
|
fields = frappe.get_meta(doctype).fields
|
||||||
fields = [field for field in fields if field.fieldname in allowed_fields]
|
fields = [field for field in fields if field.fieldname in allowed_fields]
|
||||||
|
|
||||||
|
required_fields = []
|
||||||
|
|
||||||
|
if type == "Required Fields":
|
||||||
|
required_fields = [
|
||||||
|
field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default
|
||||||
|
]
|
||||||
|
|
||||||
for tab in tabs:
|
for tab in tabs:
|
||||||
for section in tab.get("sections"):
|
for section in tab.get("sections"):
|
||||||
|
if section.get("columns"):
|
||||||
|
section["columns"] = [column for column in section.get("columns") if column]
|
||||||
for column in section.get("columns") if section.get("columns") else []:
|
for column in section.get("columns") if section.get("columns") else []:
|
||||||
|
column["fields"] = [field for field in column.get("fields") if field]
|
||||||
for field in column.get("fields") if column.get("fields") else []:
|
for field in column.get("fields") if column.get("fields") else []:
|
||||||
field = next((f for f in fields if f.fieldname == field), None)
|
field = next((f for f in fields if f.fieldname == field), None)
|
||||||
if field:
|
if field:
|
||||||
@ -55,6 +67,32 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
|||||||
handle_perm_level_restrictions(field, doctype, parent_doctype)
|
handle_perm_level_restrictions(field, doctype, parent_doctype)
|
||||||
column["fields"][column.get("fields").index(field["fieldname"])] = field
|
column["fields"][column.get("fields").index(field["fieldname"])] = field
|
||||||
|
|
||||||
|
# remove field from required_fields if it is already present
|
||||||
|
if (
|
||||||
|
type == "Required Fields"
|
||||||
|
and field.reqd
|
||||||
|
and any(f.get("fieldname") == field.get("fieldname") for f in required_fields)
|
||||||
|
):
|
||||||
|
required_fields = [
|
||||||
|
f for f in required_fields if f.get("fieldname") != field.get("fieldname")
|
||||||
|
]
|
||||||
|
|
||||||
|
if type == "Required Fields" and required_fields and tabs:
|
||||||
|
tabs[-1].get("sections").append(
|
||||||
|
{
|
||||||
|
"label": "Required Fields",
|
||||||
|
"name": "required_fields_section_" + str(random_string(4)),
|
||||||
|
"opened": True,
|
||||||
|
"hideLabel": True,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "required_fields_column_" + str(random_string(4)),
|
||||||
|
"fields": [field.as_dict() for field in required_fields],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return tabs or []
|
return tabs or []
|
||||||
|
|
||||||
|
|
||||||
@ -78,6 +116,8 @@ def get_sidepanel_sections(doctype):
|
|||||||
fields = frappe.get_meta(doctype).fields
|
fields = frappe.get_meta(doctype).fields
|
||||||
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
||||||
|
|
||||||
|
add_forecasting_section(layout, doctype)
|
||||||
|
|
||||||
for section in layout:
|
for section in layout:
|
||||||
section["name"] = section.get("name") or section.get("label")
|
section["name"] = section.get("name") or section.get("label")
|
||||||
for column in section.get("columns") if section.get("columns") else []:
|
for column in section.get("columns") if section.get("columns") else []:
|
||||||
@ -95,6 +135,38 @@ def get_sidepanel_sections(doctype):
|
|||||||
return layout
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
def add_forecasting_section(layout, doctype):
|
||||||
|
if (
|
||||||
|
doctype == "CRM Deal"
|
||||||
|
and frappe.db.get_single_value("FCRM Settings", "enable_forecasting")
|
||||||
|
and not any(section.get("name") == "forecasted_sales_section" for section in layout)
|
||||||
|
):
|
||||||
|
contacts_section_index = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, section in enumerate(layout)
|
||||||
|
if section.get("name") == "contacts_section" or section.get("label") == "Contacts"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if contacts_section_index is not None:
|
||||||
|
layout.insert(
|
||||||
|
contacts_section_index + 1,
|
||||||
|
{
|
||||||
|
"name": "forecasted_sales_section",
|
||||||
|
"label": "Forecasted Sales",
|
||||||
|
"opened": True,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "column_" + str(random_string(4)),
|
||||||
|
"fields": ["expected_closure_date", "probability", "expected_deal_value"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
|
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
|
||||||
if field.permlevel == 0:
|
if field.permlevel == 0:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -65,7 +65,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-09-16 19:40:19.340948",
|
"modified": "2025-05-19 17:57:24.610295",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Form Script",
|
"name": "CRM Form Script",
|
||||||
@ -83,9 +83,19 @@
|
|||||||
"role": "Sales Manager",
|
"role": "Sales Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "All",
|
||||||
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Role",
|
"label": "Role",
|
||||||
"options": "\nSales User\nSales Manager",
|
"options": "\nSales User\nSales Manager\nSystem Manager",
|
||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -66,7 +66,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-09-03 14:59:29.450018",
|
"modified": "2025-06-17 17:20:18.935395",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Invitation",
|
"name": "CRM Invitation",
|
||||||
@ -106,7 +106,8 @@
|
|||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class CRMInvitation(Document):
|
|||||||
if frappe.local.dev_server:
|
if frappe.local.dev_server:
|
||||||
print(f"Invite link for {self.email}: {invite_link}")
|
print(f"Invite link for {self.email}: {invite_link}")
|
||||||
|
|
||||||
title = f"Frappe CRM"
|
title = "Frappe CRM"
|
||||||
template = "crm_invitation"
|
template = "crm_invitation"
|
||||||
|
|
||||||
frappe.sendmail(
|
frappe.sendmail(
|
||||||
@ -35,7 +35,7 @@ class CRMInvitation(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def accept_invitation(self):
|
def accept_invitation(self):
|
||||||
frappe.only_for("System Manager")
|
frappe.only_for(["System Manager", "Sales Manager"])
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
@ -44,12 +44,28 @@ class CRMInvitation(Document):
|
|||||||
|
|
||||||
user = self.create_user_if_not_exists()
|
user = self.create_user_if_not_exists()
|
||||||
user.append_roles(self.role)
|
user.append_roles(self.role)
|
||||||
|
if self.role == "System Manager":
|
||||||
|
user.append_roles("Sales Manager", "Sales User")
|
||||||
|
elif self.role == "Sales Manager":
|
||||||
|
user.append_roles("Sales User")
|
||||||
|
if self.role == "Sales User":
|
||||||
|
self.update_module_in_user(user, "FCRM")
|
||||||
user.save(ignore_permissions=True)
|
user.save(ignore_permissions=True)
|
||||||
|
|
||||||
self.status = "Accepted"
|
self.status = "Accepted"
|
||||||
self.accepted_at = frappe.utils.now()
|
self.accepted_at = frappe.utils.now()
|
||||||
self.save(ignore_permissions=True)
|
self.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
def update_module_in_user(self, user, module):
|
||||||
|
block_modules = frappe.get_all(
|
||||||
|
"Module Def",
|
||||||
|
fields=["name as module"],
|
||||||
|
filters={"name": ["!=", module]},
|
||||||
|
)
|
||||||
|
|
||||||
|
if block_modules:
|
||||||
|
user.set("block_modules", block_modules)
|
||||||
|
|
||||||
def create_user_if_not_exists(self):
|
def create_user_if_not_exists(self):
|
||||||
if not frappe.db.exists("User", self.email):
|
if not frappe.db.exists("User", self.email):
|
||||||
first_name = self.email.split("@")[0].title()
|
first_name = self.email.split("@")[0].title()
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import frappe
|
|
||||||
|
|
||||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
|
||||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def get_lead(name):
|
|
||||||
lead = frappe.get_doc("CRM Lead", name).as_dict()
|
|
||||||
|
|
||||||
lead["fields_meta"] = get_fields_meta("CRM Lead")
|
|
||||||
lead["_form_script"] = get_form_script("CRM Lead")
|
|
||||||
lead["_assign"] = get_assigned_users("CRM Lead", lead.name)
|
|
||||||
return lead
|
|
||||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Lead", {
|
|||||||
refresh(frm) {
|
refresh(frm) {
|
||||||
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
|
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
|
||||||
},
|
},
|
||||||
|
update_total: function (frm) {
|
||||||
|
let total = 0;
|
||||||
|
let total_qty = 0;
|
||||||
|
let net_total = 0;
|
||||||
|
frm.doc.products.forEach((d) => {
|
||||||
|
total += d.amount;
|
||||||
|
total_qty += d.qty;
|
||||||
|
net_total += d.net_amount;
|
||||||
|
});
|
||||||
|
|
||||||
|
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||||
|
frappe.model.set_value(
|
||||||
|
frm.doctype,
|
||||||
|
frm.docname,
|
||||||
|
"net_total",
|
||||||
|
net_total || total
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
frappe.ui.form.on("CRM Products", {
|
||||||
|
products_add: function (frm, cdt, cdn) {
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
products_remove: function (frm, cdt, cdn) {
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
product_code: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||||
|
},
|
||||||
|
rate: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
if (d.rate && d.qty) {
|
||||||
|
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||||
|
}
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
qty: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
if (d.rate && d.qty) {
|
||||||
|
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||||
|
}
|
||||||
|
frm.trigger("update_total");
|
||||||
|
},
|
||||||
|
discount_percentage: function (frm, cdt, cdn) {
|
||||||
|
let d = frappe.get_doc(cdt, cdn);
|
||||||
|
if (d.discount_percentage && d.amount) {
|
||||||
|
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||||
|
frappe.model.set_value(
|
||||||
|
cdt,
|
||||||
|
cdn,
|
||||||
|
"discount_amount",
|
||||||
|
discount_amount
|
||||||
|
);
|
||||||
|
frappe.model.set_value(
|
||||||
|
cdt,
|
||||||
|
cdn,
|
||||||
|
"net_amount",
|
||||||
|
d.amount - discount_amount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frm.trigger("update_total");
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -37,6 +37,12 @@
|
|||||||
"annual_revenue",
|
"annual_revenue",
|
||||||
"image",
|
"image",
|
||||||
"converted",
|
"converted",
|
||||||
|
"products_tab",
|
||||||
|
"products",
|
||||||
|
"section_break_ggwh",
|
||||||
|
"total",
|
||||||
|
"column_break_uisv",
|
||||||
|
"net_total",
|
||||||
"sla_tab",
|
"sla_tab",
|
||||||
"sla",
|
"sla",
|
||||||
"sla_creation",
|
"sla_creation",
|
||||||
@ -49,7 +55,11 @@
|
|||||||
"first_response_time",
|
"first_response_time",
|
||||||
"first_responded_on",
|
"first_responded_on",
|
||||||
"log_tab",
|
"log_tab",
|
||||||
"status_change_log"
|
"status_change_log",
|
||||||
|
"syncing_tab",
|
||||||
|
"facebook_lead_id",
|
||||||
|
"column_break_ixmu",
|
||||||
|
"facebook_form_id"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -285,12 +295,67 @@
|
|||||||
"fieldtype": "Table",
|
"fieldtype": "Table",
|
||||||
"label": "Status Change Log",
|
"label": "Status Change Log",
|
||||||
"options": "CRM Status Change Log"
|
"options": "CRM Status Change Log"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "products_tab",
|
||||||
|
"fieldtype": "Tab Break",
|
||||||
|
"label": "Products"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "products",
|
||||||
|
"fieldtype": "Table",
|
||||||
|
"label": "Products",
|
||||||
|
"options": "CRM Products"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_ggwh",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "total",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Total",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_uisv",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Total after discount",
|
||||||
|
"fieldname": "net_total",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Net Total",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "syncing_tab",
|
||||||
|
"fieldtype": "Tab Break",
|
||||||
|
"label": "Syncing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "facebook_lead_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Facebook Lead ID",
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ixmu",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "facebook_form_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Facebook Form ID"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"image_field": "image",
|
"image_field": "image",
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-02 22:14:01.991054",
|
"modified": "2025-10-19 18:36:24.683076",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Lead",
|
"name": "CRM Lead",
|
||||||
@ -331,6 +396,7 @@
|
|||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sender_field": "email",
|
"sender_field": "email",
|
||||||
"sender_name_field": "first_name",
|
"sender_name_field": "first_name",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
@ -339,4 +405,4 @@
|
|||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "lead_name",
|
"title_field": "lead_name",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,53 @@ from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
|||||||
|
|
||||||
|
|
||||||
class CRMLead(Document):
|
class CRMLead(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from crm.fcrm.doctype.crm_products.crm_products import CRMProducts
|
||||||
|
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import CRMStatusChangeLog
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
annual_revenue: DF.Currency
|
||||||
|
communication_status: DF.Link | None
|
||||||
|
converted: DF.Check
|
||||||
|
email: DF.Data | None
|
||||||
|
facebook_form_id: DF.Data | None
|
||||||
|
facebook_lead_id: DF.Data | None
|
||||||
|
first_name: DF.Data
|
||||||
|
first_responded_on: DF.Datetime | None
|
||||||
|
first_response_time: DF.Duration | None
|
||||||
|
gender: DF.Link | None
|
||||||
|
image: DF.AttachImage | None
|
||||||
|
industry: DF.Link | None
|
||||||
|
job_title: DF.Data | None
|
||||||
|
last_name: DF.Data | None
|
||||||
|
lead_name: DF.Data | None
|
||||||
|
lead_owner: DF.Link | None
|
||||||
|
middle_name: DF.Data | None
|
||||||
|
mobile_no: DF.Data | None
|
||||||
|
naming_series: DF.Literal["CRM-LEAD-.YYYY.-"]
|
||||||
|
net_total: DF.Currency
|
||||||
|
no_of_employees: DF.Literal["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"]
|
||||||
|
organization: DF.Data | None
|
||||||
|
phone: DF.Data | None
|
||||||
|
products: DF.Table[CRMProducts]
|
||||||
|
response_by: DF.Datetime | None
|
||||||
|
salutation: DF.Link | None
|
||||||
|
sla: DF.Link | None
|
||||||
|
sla_creation: DF.Datetime | None
|
||||||
|
sla_status: DF.Literal["", "First Response Due", "Failed", "Fulfilled"]
|
||||||
|
source: DF.Link | None
|
||||||
|
status: DF.Link
|
||||||
|
status_change_log: DF.Table[CRMStatusChangeLog]
|
||||||
|
territory: DF.Link | None
|
||||||
|
total: DF.Currency
|
||||||
|
website: DF.Data | None
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
def before_validate(self):
|
def before_validate(self):
|
||||||
self.set_sla()
|
self.set_sla()
|
||||||
|
|
||||||
|
|||||||
@ -27,9 +27,10 @@
|
|||||||
"label": "Details"
|
"label": "Details"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-02 22:13:30.498404",
|
"modified": "2025-06-30 16:53:51.721752",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Lead Source",
|
"name": "CRM Lead Source",
|
||||||
@ -44,7 +45,7 @@
|
|||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "Sales User",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
@ -60,6 +61,15 @@
|
|||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales User",
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
@ -71,7 +81,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"quick_entry": 1,
|
"quick_entry": 1,
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("CRM Lost Reason", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "field:lost_reason",
|
||||||
|
"creation": "2025-06-30 16:51:31.082360",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"lost_reason",
|
||||||
|
"description"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "lost_reason",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Lost Reason",
|
||||||
|
"reqd": 1,
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "description",
|
||||||
|
"fieldtype": "Text Editor",
|
||||||
|
"label": "Description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-06-30 16:59:15.094049",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "CRM Lost Reason",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales User",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quick_entry": 1,
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class CRMLostReason(Document):
|
||||||
|
pass
|
||||||
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
class UnitTestCRMLostReason(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for CRMLostReason.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestCRMLostReason(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for CRMLostReason.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@ -10,6 +10,7 @@
|
|||||||
"organization_name",
|
"organization_name",
|
||||||
"no_of_employees",
|
"no_of_employees",
|
||||||
"currency",
|
"currency",
|
||||||
|
"exchange_rate",
|
||||||
"annual_revenue",
|
"annual_revenue",
|
||||||
"organization_logo",
|
"organization_logo",
|
||||||
"column_break_pnpp",
|
"column_break_pnpp",
|
||||||
@ -74,12 +75,18 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Address",
|
"label": "Address",
|
||||||
"options": "Address"
|
"options": "Address"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "The rate used to convert the organization\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||||
|
"fieldname": "exchange_rate",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Exchange Rate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"image_field": "organization_logo",
|
"image_field": "organization_logo",
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-09-17 18:37:10.341062",
|
"modified": "2025-07-15 11:40:12.175598",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Organization",
|
"name": "CRM Organization",
|
||||||
@ -111,7 +118,8 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,51 +4,65 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
|
||||||
|
|
||||||
|
|
||||||
class CRMOrganization(Document):
|
class CRMOrganization(Document):
|
||||||
@staticmethod
|
def validate(self):
|
||||||
def default_list_data():
|
self.update_exchange_rate()
|
||||||
columns = [
|
|
||||||
{
|
def update_exchange_rate(self):
|
||||||
'label': 'Organization',
|
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||||
'type': 'Data',
|
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||||
'key': 'organization_name',
|
exchange_rate = 1
|
||||||
'width': '16rem',
|
if self.currency and self.currency != system_currency:
|
||||||
},
|
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
||||||
{
|
|
||||||
'label': 'Website',
|
self.db_set("exchange_rate", exchange_rate)
|
||||||
'type': 'Data',
|
|
||||||
'key': 'website',
|
@staticmethod
|
||||||
'width': '14rem',
|
def default_list_data():
|
||||||
},
|
columns = [
|
||||||
{
|
{
|
||||||
'label': 'Industry',
|
"label": "Organization",
|
||||||
'type': 'Link',
|
"type": "Data",
|
||||||
'key': 'industry',
|
"key": "organization_name",
|
||||||
'options': 'CRM Industry',
|
"width": "16rem",
|
||||||
'width': '14rem',
|
},
|
||||||
},
|
{
|
||||||
{
|
"label": "Website",
|
||||||
'label': 'Annual Revenue',
|
"type": "Data",
|
||||||
'type': 'Currency',
|
"key": "website",
|
||||||
'key': 'annual_revenue',
|
"width": "14rem",
|
||||||
'width': '14rem',
|
},
|
||||||
},
|
{
|
||||||
{
|
"label": "Industry",
|
||||||
'label': 'Last Modified',
|
"type": "Link",
|
||||||
'type': 'Datetime',
|
"key": "industry",
|
||||||
'key': 'modified',
|
"options": "CRM Industry",
|
||||||
'width': '8rem',
|
"width": "14rem",
|
||||||
},
|
},
|
||||||
]
|
{
|
||||||
rows = [
|
"label": "Annual Revenue",
|
||||||
"name",
|
"type": "Currency",
|
||||||
"organization_name",
|
"key": "annual_revenue",
|
||||||
"organization_logo",
|
"width": "14rem",
|
||||||
"website",
|
},
|
||||||
"industry",
|
{
|
||||||
"currency",
|
"label": "Last Modified",
|
||||||
"annual_revenue",
|
"type": "Datetime",
|
||||||
"modified",
|
"key": "modified",
|
||||||
]
|
"width": "8rem",
|
||||||
return {'columns': columns, 'rows': rows}
|
},
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
"name",
|
||||||
|
"organization_name",
|
||||||
|
"organization_logo",
|
||||||
|
"website",
|
||||||
|
"industry",
|
||||||
|
"currency",
|
||||||
|
"annual_revenue",
|
||||||
|
"modified",
|
||||||
|
]
|
||||||
|
return {"columns": columns, "rows": rows}
|
||||||
|
|||||||
0
crm/fcrm/doctype/crm_product/__init__.py
Normal file
0
crm/fcrm/doctype/crm_product/__init__.py
Normal file
9
crm/fcrm/doctype/crm_product/crm_product.js
Normal file
9
crm/fcrm/doctype/crm_product/crm_product.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on("CRM Product", {
|
||||||
|
product_code: function (frm) {
|
||||||
|
if (!frm.doc.product_name)
|
||||||
|
frm.set_value("product_name", frm.doc.product_code);
|
||||||
|
}
|
||||||
|
});
|
||||||
105
crm/fcrm/doctype/crm_product/crm_product.json
Normal file
105
crm/fcrm/doctype/crm_product/crm_product.json
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_import": 1,
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "field:product_code",
|
||||||
|
"creation": "2025-04-28 11:45:09.309636",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"naming_series",
|
||||||
|
"product_code",
|
||||||
|
"product_name",
|
||||||
|
"column_break_bpdj",
|
||||||
|
"disabled",
|
||||||
|
"standard_rate",
|
||||||
|
"image",
|
||||||
|
"section_break_rtwm",
|
||||||
|
"description"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "naming_series",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Naming Series",
|
||||||
|
"options": "CRM-PROD-.YYYY.-"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "product_code",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Product Code",
|
||||||
|
"reqd": 1,
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "product_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Product Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_bpdj",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "disabled",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Disabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "image",
|
||||||
|
"fieldtype": "Attach Image",
|
||||||
|
"label": "Image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_rtwm",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "description",
|
||||||
|
"fieldtype": "Text Editor",
|
||||||
|
"label": "Description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "standard_rate",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Standard Selling Rate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"image_field": "image",
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"make_attachments_public": 1,
|
||||||
|
"modified": "2025-04-28 12:47:25.087957",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "CRM Product",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quick_entry": 1,
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"search_fields": "product_name,description",
|
||||||
|
"show_name_in_global_search": 1,
|
||||||
|
"show_preview_popup": 1,
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"title_field": "product_name",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
16
crm/fcrm/doctype/crm_product/crm_product.py
Normal file
16
crm/fcrm/doctype/crm_product/crm_product.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class CRMProduct(Document):
|
||||||
|
def validate(self):
|
||||||
|
self.set_product_name()
|
||||||
|
|
||||||
|
def set_product_name(self):
|
||||||
|
if not self.product_name:
|
||||||
|
self.product_name = self.product_code
|
||||||
|
else:
|
||||||
|
self.product_name = self.product_name.strip()
|
||||||
29
crm/fcrm/doctype/crm_product/test_crm_product.py
Normal file
29
crm/fcrm/doctype/crm_product/test_crm_product.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
class UnitTestCRMProduct(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for CRMProduct.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestCRMProduct(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for CRMProduct.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
0
crm/fcrm/doctype/crm_products/__init__.py
Normal file
0
crm/fcrm/doctype/crm_products/__init__.py
Normal file
136
crm/fcrm/doctype/crm_products/crm_products.json
Normal file
136
crm/fcrm/doctype/crm_products/crm_products.json
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2025-04-28 12:50:49.812915",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"product_code",
|
||||||
|
"column_break_gvbc",
|
||||||
|
"product_name",
|
||||||
|
"section_break_fnvf",
|
||||||
|
"qty",
|
||||||
|
"column_break_ajac",
|
||||||
|
"rate",
|
||||||
|
"section_break_olqb",
|
||||||
|
"discount_percentage",
|
||||||
|
"column_break_uvra",
|
||||||
|
"discount_amount",
|
||||||
|
"section_break_cnpb",
|
||||||
|
"column_break_pozr",
|
||||||
|
"amount",
|
||||||
|
"column_break_ejqw",
|
||||||
|
"net_amount"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_gvbc",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "product_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Product Name",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_fnvf",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_olqb",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bold": 1,
|
||||||
|
"fieldname": "discount_percentage",
|
||||||
|
"fieldtype": "Percent",
|
||||||
|
"label": "Discount %"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "discount_amount",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Discount Amount",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_cnpb",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_pozr",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bold": 1,
|
||||||
|
"fieldname": "rate",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Rate",
|
||||||
|
"options": "currency",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bold": 1,
|
||||||
|
"fieldname": "amount",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Amount",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bold": 1,
|
||||||
|
"depends_on": "discount_percentage",
|
||||||
|
"description": "Amount after discount",
|
||||||
|
"fieldname": "net_amount",
|
||||||
|
"fieldtype": "Currency",
|
||||||
|
"label": "Net Amount",
|
||||||
|
"options": "currency",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bold": 1,
|
||||||
|
"columns": 5,
|
||||||
|
"fieldname": "product_code",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Product",
|
||||||
|
"options": "CRM Product"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bold": 1,
|
||||||
|
"default": "1",
|
||||||
|
"fieldname": "qty",
|
||||||
|
"fieldtype": "Float",
|
||||||
|
"label": "Quantity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ajac",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_uvra",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ejqw",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"istable": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-05-14 18:52:26.183306",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "CRM Products",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
110
crm/fcrm/doctype/crm_products/crm_products.py
Normal file
110
crm/fcrm/doctype/crm_products/crm_products.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class CRMProducts(Document):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_product_details_script(doctype):
|
||||||
|
if not frappe.db.exists("CRM Form Script", "Product Details Script for " + doctype):
|
||||||
|
script = get_product_details_script(doctype)
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "CRM Form Script",
|
||||||
|
"name": "Product Details Script for " + doctype,
|
||||||
|
"dt": doctype,
|
||||||
|
"view": "Form",
|
||||||
|
"script": script,
|
||||||
|
"enabled": 1,
|
||||||
|
"is_standard": 1,
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
|
||||||
|
def get_product_details_script(doctype):
|
||||||
|
doctype_class = "class " + doctype.replace(" ", "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
doctype_class
|
||||||
|
+ " {"
|
||||||
|
+ """
|
||||||
|
update_total() {
|
||||||
|
let total = 0
|
||||||
|
let total_qty = 0
|
||||||
|
let net_total = 0
|
||||||
|
let discount_applied = false
|
||||||
|
|
||||||
|
this.doc.products.forEach((d) => {
|
||||||
|
total += d.amount
|
||||||
|
net_total += d.net_amount
|
||||||
|
if (d.discount_percentage > 0) {
|
||||||
|
discount_applied = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.doc.total = total
|
||||||
|
this.doc.net_total = net_total || total
|
||||||
|
|
||||||
|
if (!net_total && discount_applied) {
|
||||||
|
this.doc.net_total = net_total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CRMProducts {
|
||||||
|
products_add() {
|
||||||
|
let row = this.doc.getRow('products')
|
||||||
|
row.trigger('qty')
|
||||||
|
this.doc.trigger('update_total')
|
||||||
|
}
|
||||||
|
|
||||||
|
products_remove() {
|
||||||
|
this.doc.trigger('update_total')
|
||||||
|
}
|
||||||
|
|
||||||
|
async product_code(idx) {
|
||||||
|
let row = this.doc.getRow('products', idx)
|
||||||
|
|
||||||
|
let a = await call("frappe.client.get_value", {
|
||||||
|
doctype: "CRM Product",
|
||||||
|
filters: { name: row.product_code },
|
||||||
|
fieldname: ["product_name", "standard_rate"],
|
||||||
|
})
|
||||||
|
|
||||||
|
row.product_name = a.product_name
|
||||||
|
if (a.standard_rate && !row.rate) {
|
||||||
|
row.rate = a.standard_rate
|
||||||
|
row.trigger("rate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qty(idx) {
|
||||||
|
let row = this.doc.getRow('products', idx)
|
||||||
|
row.amount = row.qty * row.rate
|
||||||
|
row.trigger('discount_percentage', idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
rate() {
|
||||||
|
let row = this.doc.getRow('products')
|
||||||
|
row.amount = row.qty * row.rate
|
||||||
|
row.trigger('discount_percentage')
|
||||||
|
}
|
||||||
|
|
||||||
|
discount_percentage(idx) {
|
||||||
|
let row = this.doc.getRow('products', idx)
|
||||||
|
if (!row.discount_percentage) {
|
||||||
|
row.net_amount = row.amount
|
||||||
|
row.discount_amount = 0
|
||||||
|
}
|
||||||
|
if (row.discount_percentage && row.amount) {
|
||||||
|
row.discount_amount = (row.discount_percentage / 100) * row.amount
|
||||||
|
row.net_amount = row.amount - row.discount_amount
|
||||||
|
}
|
||||||
|
this.doc.trigger('update_total')
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
)
|
||||||
@ -13,6 +13,8 @@
|
|||||||
"column_break_mwmz",
|
"column_break_mwmz",
|
||||||
"duration",
|
"duration",
|
||||||
"last_status_change_log",
|
"last_status_change_log",
|
||||||
|
"from_type",
|
||||||
|
"to_type",
|
||||||
"log_owner"
|
"log_owner"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
@ -61,18 +63,31 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Owner",
|
"label": "Owner",
|
||||||
"options": "User"
|
"options": "User"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "from_type",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "From Type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "to_type",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "To Type"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-01-06 13:26:40.597277",
|
"modified": "2025-07-13 12:37:41.278584",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Status Change Log",
|
"name": "CRM Status Change Log",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
import frappe
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from frappe.utils import add_to_date, get_datetime
|
|
||||||
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import add_to_date, get_datetime
|
||||||
|
|
||||||
|
|
||||||
class CRMStatusChangeLog(Document):
|
class CRMStatusChangeLog(Document):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_duration(from_date, to_date):
|
def get_duration(from_date, to_date):
|
||||||
if not isinstance(from_date, datetime):
|
if not isinstance(from_date, datetime):
|
||||||
from_date = get_datetime(from_date)
|
from_date = get_datetime(from_date)
|
||||||
@ -18,28 +20,45 @@ def get_duration(from_date, to_date):
|
|||||||
duration = to_date - from_date
|
duration = to_date - from_date
|
||||||
return duration.total_seconds()
|
return duration.total_seconds()
|
||||||
|
|
||||||
|
|
||||||
def add_status_change_log(doc):
|
def add_status_change_log(doc):
|
||||||
|
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None
|
||||||
|
|
||||||
if not doc.is_new():
|
if not doc.is_new():
|
||||||
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
|
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
|
||||||
|
previous_status_type = (
|
||||||
|
frappe.db.get_value("CRM Deal Status", previous_status, "type") if previous_status else None
|
||||||
|
)
|
||||||
if not doc.status_change_log and previous_status:
|
if not doc.status_change_log and previous_status:
|
||||||
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
|
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
|
||||||
doc.append("status_change_log", {
|
doc.append(
|
||||||
"from": previous_status,
|
"status_change_log",
|
||||||
"to": "",
|
{
|
||||||
"from_date": now_minus_one_minute,
|
"from": previous_status,
|
||||||
"to_date": "",
|
"from_type": previous_status_type or "",
|
||||||
"log_owner": frappe.session.user,
|
"to": "",
|
||||||
})
|
"to_type": "",
|
||||||
|
"from_date": now_minus_one_minute,
|
||||||
|
"to_date": "",
|
||||||
|
"log_owner": frappe.session.user,
|
||||||
|
},
|
||||||
|
)
|
||||||
last_status_change = doc.status_change_log[-1]
|
last_status_change = doc.status_change_log[-1]
|
||||||
last_status_change.to = doc.status
|
last_status_change.to = doc.status
|
||||||
|
last_status_change.to_type = to_status_type or ""
|
||||||
last_status_change.to_date = datetime.now()
|
last_status_change.to_date = datetime.now()
|
||||||
last_status_change.log_owner = frappe.session.user
|
last_status_change.log_owner = frappe.session.user
|
||||||
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
|
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
|
||||||
|
|
||||||
doc.append("status_change_log", {
|
doc.append(
|
||||||
"from": doc.status,
|
"status_change_log",
|
||||||
"to": "",
|
{
|
||||||
"from_date": datetime.now(),
|
"from": doc.status,
|
||||||
"to_date": "",
|
"from_type": to_status_type or "",
|
||||||
"log_owner": frappe.session.user,
|
"to": "",
|
||||||
})
|
"to_type": "",
|
||||||
|
"from_date": datetime.now(),
|
||||||
|
"to_date": "",
|
||||||
|
"log_owner": frappe.session.user,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@ -63,8 +63,7 @@
|
|||||||
"fieldname": "twiml_sid",
|
"fieldname": "twiml_sid",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "TwiML SID",
|
"label": "TwiML SID",
|
||||||
"permlevel": 1,
|
"permlevel": 1
|
||||||
"read_only": 1
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "section_break_ssqj",
|
"fieldname": "section_break_ssqj",
|
||||||
@ -105,7 +104,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-15 19:35:13.406254",
|
"modified": "2025-08-19 13:36:19.823197",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Twilio Settings",
|
"name": "CRM Twilio Settings",
|
||||||
@ -152,8 +151,9 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -128,14 +128,32 @@ def get_quotation_url(crm_deal, organization):
|
|||||||
address = address.get("name") if address else None
|
address = address.get("name") if address else None
|
||||||
|
|
||||||
if not erpnext_crm_settings.is_erpnext_in_different_site:
|
if not erpnext_crm_settings.is_erpnext_in_different_site:
|
||||||
quotation_url = get_url_to_list("Quotation")
|
base_url = f"{get_url_to_list('Quotation')}/new"
|
||||||
return f"{quotation_url}/new?quotation_to=CRM Deal&crm_deal={crm_deal}&party_name={crm_deal}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
|
params = {
|
||||||
|
"quotation_to": "CRM Deal",
|
||||||
|
"crm_deal": crm_deal,
|
||||||
|
"party_name": crm_deal,
|
||||||
|
"company": erpnext_crm_settings.erpnext_company,
|
||||||
|
"contact_person": contact,
|
||||||
|
"customer_address": address,
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
site_url = erpnext_crm_settings.get("erpnext_site_url")
|
site_url = erpnext_crm_settings.get("erpnext_site_url")
|
||||||
quotation_url = f"{site_url}/app/quotation"
|
base_url = f"{site_url}/app/quotation/new"
|
||||||
|
|
||||||
prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings)
|
prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings)
|
||||||
return f"{quotation_url}/new?quotation_to=Prospect&crm_deal={crm_deal}&party_name={prospect}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
|
params = {
|
||||||
|
"quotation_to": "Prospect",
|
||||||
|
"crm_deal": crm_deal,
|
||||||
|
"party_name": prospect,
|
||||||
|
"company": erpnext_crm_settings.erpnext_company,
|
||||||
|
"contact_person": contact,
|
||||||
|
"customer_address": address,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Filter out None values and build query string
|
||||||
|
query_string = "&".join(f"{key}={value}" for key, value in params.items() if value is not None)
|
||||||
|
|
||||||
|
return f"{base_url}?{query_string}"
|
||||||
|
|
||||||
|
|
||||||
def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings):
|
def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings):
|
||||||
@ -264,7 +282,7 @@ def create_customer_in_remote_site(customer, erpnext_crm_settings):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_crm_form_script():
|
def get_crm_form_script():
|
||||||
return """
|
return """
|
||||||
async function setupForm({ doc, call, $dialog, updateField, createToast }) {
|
async function setupForm({ doc, call, $dialog, updateField, toast }) {
|
||||||
let actions = [];
|
let actions = [];
|
||||||
let is_erpnext_integration_enabled = await call("frappe.client.get_single_value", {doctype: "ERPNext CRM Settings", field: "enabled"});
|
let is_erpnext_integration_enabled = await call("frappe.client.get_single_value", {doctype: "ERPNext CRM Settings", field: "enabled"});
|
||||||
if (!["Lost", "Won"].includes(doc?.status) && is_erpnext_integration_enabled) {
|
if (!["Lost", "Won"].includes(doc?.status) && is_erpnext_integration_enabled) {
|
||||||
|
|||||||
@ -19,7 +19,8 @@
|
|||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Title"
|
"label": "Title",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "content",
|
"fieldname": "content",
|
||||||
@ -49,7 +50,7 @@
|
|||||||
"link_fieldname": "note"
|
"link_fieldname": "note"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2024-01-19 21:56:30.123334",
|
"modified": "2025-04-01 15:30:14.742001",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "FCRM Note",
|
"name": "FCRM Note",
|
||||||
|
|||||||
@ -7,6 +7,14 @@
|
|||||||
"field_order": [
|
"field_order": [
|
||||||
"defaults_tab",
|
"defaults_tab",
|
||||||
"restore_defaults",
|
"restore_defaults",
|
||||||
|
"enable_forecasting",
|
||||||
|
"auto_update_expected_deal_value",
|
||||||
|
"currency_tab",
|
||||||
|
"currency",
|
||||||
|
"exchange_rate_provider_section",
|
||||||
|
"service_provider",
|
||||||
|
"column_break_vqck",
|
||||||
|
"access_key",
|
||||||
"branding_tab",
|
"branding_tab",
|
||||||
"brand_name",
|
"brand_name",
|
||||||
"brand_logo",
|
"brand_logo",
|
||||||
@ -28,7 +36,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "defaults_tab",
|
"fieldname": "defaults_tab",
|
||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
"label": "Defaults"
|
"label": "Settings"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "branding_tab",
|
"fieldname": "branding_tab",
|
||||||
@ -56,12 +64,61 @@
|
|||||||
"fieldname": "favicon",
|
"fieldname": "favicon",
|
||||||
"fieldtype": "Attach",
|
"fieldtype": "Attach",
|
||||||
"label": "Favicon"
|
"label": "Favicon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights",
|
||||||
|
"fieldname": "enable_forecasting",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Enable Forecasting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "currency",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Currency",
|
||||||
|
"options": "Currency"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "currency_tab",
|
||||||
|
"fieldtype": "Tab Break",
|
||||||
|
"label": "Currency"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "exchange_rate_provider_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Exchange Rate Provider"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "frankfurter.app",
|
||||||
|
"fieldname": "service_provider",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Service Provider",
|
||||||
|
"options": "frankfurter.app\nexchangerate.host"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:doc.service_provider == 'exchangerate.host';",
|
||||||
|
"fieldname": "access_key",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Access Key",
|
||||||
|
"mandatory_depends_on": "eval:doc.service_provider == 'exchangerate.host';"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_vqck",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"description": "Automatically update \"Expected Deal Value\" based on the total value of associated products in a deal",
|
||||||
|
"fieldname": "auto_update_expected_deal_value",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Auto Update Expected Deal Value"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-02-20 12:38:38.088477",
|
"modified": "2025-09-16 17:33:26.406549",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "FCRM Settings",
|
"name": "FCRM Settings",
|
||||||
@ -95,7 +152,8 @@
|
|||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
import requests
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
|
||||||
from crm.install import after_install
|
from crm.install import after_install
|
||||||
@ -15,6 +17,8 @@ class FCRMSettings(Document):
|
|||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.do_not_allow_to_delete_if_standard()
|
self.do_not_allow_to_delete_if_standard()
|
||||||
|
self.setup_forecasting()
|
||||||
|
self.make_currency_read_only()
|
||||||
|
|
||||||
def do_not_allow_to_delete_if_standard(self):
|
def do_not_allow_to_delete_if_standard(self):
|
||||||
if not self.has_value_changed("dropdown_items"):
|
if not self.has_value_changed("dropdown_items"):
|
||||||
@ -24,8 +28,53 @@ class FCRMSettings(Document):
|
|||||||
standard_old_items = [d.name1 for d in old_items if d.is_standard]
|
standard_old_items = [d.name1 for d in old_items if d.is_standard]
|
||||||
deleted_standard_items = set(standard_old_items) - set(standard_new_items)
|
deleted_standard_items = set(standard_old_items) - set(standard_new_items)
|
||||||
if deleted_standard_items:
|
if deleted_standard_items:
|
||||||
|
standard_dropdown_items = get_standard_dropdown_items()
|
||||||
|
if not deleted_standard_items.intersection(standard_dropdown_items):
|
||||||
|
return
|
||||||
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
|
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
|
||||||
|
|
||||||
|
def setup_forecasting(self):
|
||||||
|
if self.has_value_changed("enable_forecasting"):
|
||||||
|
if not self.enable_forecasting:
|
||||||
|
delete_property_setter(
|
||||||
|
"CRM Deal",
|
||||||
|
"reqd",
|
||||||
|
"expected_closure_date",
|
||||||
|
)
|
||||||
|
delete_property_setter(
|
||||||
|
"CRM Deal",
|
||||||
|
"reqd",
|
||||||
|
"expected_deal_value",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
make_property_setter(
|
||||||
|
"CRM Deal",
|
||||||
|
"expected_closure_date",
|
||||||
|
"reqd",
|
||||||
|
1 if self.enable_forecasting else 0,
|
||||||
|
"Check",
|
||||||
|
)
|
||||||
|
make_property_setter(
|
||||||
|
"CRM Deal",
|
||||||
|
"expected_deal_value",
|
||||||
|
"reqd",
|
||||||
|
1 if self.enable_forecasting else 0,
|
||||||
|
"Check",
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_currency_read_only(self):
|
||||||
|
if self.currency and self.has_value_changed("currency"):
|
||||||
|
make_property_setter(
|
||||||
|
"FCRM Settings",
|
||||||
|
"currency",
|
||||||
|
"read_only",
|
||||||
|
1,
|
||||||
|
"Check",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_standard_dropdown_items():
|
||||||
|
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||||
|
|
||||||
|
|
||||||
def after_migrate():
|
def after_migrate():
|
||||||
@ -51,3 +100,109 @@ def sync_table(key, hook):
|
|||||||
crm_settings.set(key, items)
|
crm_settings.set(key, items)
|
||||||
|
|
||||||
crm_settings.save()
|
crm_settings.save()
|
||||||
|
|
||||||
|
|
||||||
|
def create_forecasting_script():
|
||||||
|
if not frappe.db.exists("CRM Form Script", "Forecasting Script"):
|
||||||
|
script = get_forecasting_script()
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "CRM Form Script",
|
||||||
|
"name": "Forecasting Script",
|
||||||
|
"dt": "CRM Deal",
|
||||||
|
"view": "Form",
|
||||||
|
"script": script,
|
||||||
|
"enabled": 1,
|
||||||
|
"is_standard": 1,
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
|
||||||
|
def get_forecasting_script():
|
||||||
|
return """class CRMDeal {
|
||||||
|
async status() {
|
||||||
|
await this.doc.trigger('updateProbability')
|
||||||
|
}
|
||||||
|
async updateProbability() {
|
||||||
|
let status = await call("frappe.client.get_value", {
|
||||||
|
doctype: "CRM Deal Status",
|
||||||
|
fieldname: "probability",
|
||||||
|
filters: { name: this.doc.status },
|
||||||
|
})
|
||||||
|
|
||||||
|
this.doc.probability = status.probability
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_exchange_rate(from_currency, to_currency, date=None):
|
||||||
|
if not date:
|
||||||
|
date = "latest"
|
||||||
|
|
||||||
|
api_used = "frankfurter"
|
||||||
|
|
||||||
|
api_endpoint = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
|
||||||
|
res = requests.get(api_endpoint, timeout=5)
|
||||||
|
if res.ok:
|
||||||
|
data = res.json()
|
||||||
|
return data["rates"][to_currency]
|
||||||
|
|
||||||
|
# Fallback to exchangerate.host if Frankfurter API fails
|
||||||
|
settings = FCRMSettings("FCRM Settings")
|
||||||
|
if settings and settings.service_provider == "exchangerate.host":
|
||||||
|
api_used = "exchangerate.host"
|
||||||
|
if not settings.access_key:
|
||||||
|
frappe.throw(
|
||||||
|
_("Access Key is required for Service Provider: {0}").format(
|
||||||
|
frappe.bold(settings.service_provider)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"access_key": settings.access_key,
|
||||||
|
"from": from_currency,
|
||||||
|
"to": to_currency,
|
||||||
|
"amount": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if date != "latest":
|
||||||
|
params["date"] = date
|
||||||
|
|
||||||
|
api_endpoint = "https://api.exchangerate.host/convert"
|
||||||
|
|
||||||
|
res = requests.get(api_endpoint, params=params, timeout=5)
|
||||||
|
if res.ok:
|
||||||
|
data = res.json()
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
frappe.log_error(
|
||||||
|
title="Exchange Rate Fetch Error",
|
||||||
|
message=f"Failed to fetch exchange rate from {from_currency} to {to_currency} using {api_used} API.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if api_used == "frankfurter":
|
||||||
|
user = frappe.session.user
|
||||||
|
is_manager = (
|
||||||
|
"System Manager" in frappe.get_roles(user)
|
||||||
|
or "Sales Manager" in frappe.get_roles(user)
|
||||||
|
or user == "Administrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_manager:
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"Ask your manager to set up the Exchange Rate Provider, as default provider does not support currency conversion for {0} to {1}."
|
||||||
|
).format(from_currency, to_currency)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"Setup the Exchange Rate Provider as 'Exchangerate Host' in settings, as default provider does not support currency conversion for {0} to {1}."
|
||||||
|
).format(from_currency, to_currency)
|
||||||
|
)
|
||||||
|
|
||||||
|
frappe.throw(
|
||||||
|
_(
|
||||||
|
"Failed to fetch exchange rate from {0} to {1} on {2}. Please check your internet connection or try again later."
|
||||||
|
).format(from_currency, to_currency, date)
|
||||||
|
)
|
||||||
|
|||||||
0
crm/fcrm/doctype/helpdesk_crm_settings/__init__.py
Normal file
0
crm/fcrm/doctype/helpdesk_crm_settings/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("Helpdesk CRM Settings", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2025-08-18 17:25:49.638398",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"enabled",
|
||||||
|
"column_break_idaw",
|
||||||
|
"is_helpdesk_in_different_site",
|
||||||
|
"helpdesk_site_url",
|
||||||
|
"helpdesk_site_apis_section",
|
||||||
|
"api_key",
|
||||||
|
"column_break_tqsm",
|
||||||
|
"api_secret"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "enabled",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Enabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_idaw",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"depends_on": "enabled",
|
||||||
|
"fieldname": "is_helpdesk_in_different_site",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Is Helpdesk installed on a different site?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:doc.enabled && doc.is_helpdesk_in_different_site",
|
||||||
|
"fieldname": "helpdesk_site_url",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Helpdesk Site URL",
|
||||||
|
"mandatory_depends_on": "is_helpdesk_in_different_site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "enabled",
|
||||||
|
"fieldname": "helpdesk_site_apis_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Helpdesk Site API's"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:doc.enabled && doc.is_helpdesk_in_different_site",
|
||||||
|
"fieldname": "api_key",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "API Key",
|
||||||
|
"mandatory_depends_on": "is_helpdesk_in_different_site"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_tqsm",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:doc.enabled && doc.is_helpdesk_in_different_site",
|
||||||
|
"fieldname": "api_secret",
|
||||||
|
"fieldtype": "Password",
|
||||||
|
"label": "API Secret",
|
||||||
|
"mandatory_depends_on": "is_helpdesk_in_different_site"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"issingle": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-08-18 17:33:38.616328",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "Helpdesk CRM Settings",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"role": "Sales Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
178
crm/fcrm/doctype/helpdesk_crm_settings/helpdesk_crm_settings.py
Normal file
178
crm/fcrm/doctype/helpdesk_crm_settings/helpdesk_crm_settings.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class HelpdeskCRMSettings(Document):
|
||||||
|
def validate(self):
|
||||||
|
if self.enabled:
|
||||||
|
self.validate_if_helpdesk_installed()
|
||||||
|
self.create_helpdesk_script()
|
||||||
|
|
||||||
|
def validate_if_helpdesk_installed(self):
|
||||||
|
if not self.is_helpdesk_in_different_site:
|
||||||
|
if "helpdesk" not in frappe.get_installed_apps():
|
||||||
|
frappe.throw(_("Helpdesk is not installed in the current site"))
|
||||||
|
|
||||||
|
def create_helpdesk_script(self):
|
||||||
|
if not frappe.db.exists("CRM Form Script", "Helpdesk Integration Script"):
|
||||||
|
script = get_helpdesk_script()
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "CRM Form Script",
|
||||||
|
"name": "Helpdesk Integration Script",
|
||||||
|
"dt": "CRM Deal",
|
||||||
|
"view": "Form",
|
||||||
|
"script": script,
|
||||||
|
"enabled": 1,
|
||||||
|
"is_standard": 1,
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def create_customer_in_helpdesk(name, email):
|
||||||
|
helpdesk_crm_settings = frappe.get_single("Helpdesk CRM Settings")
|
||||||
|
if not helpdesk_crm_settings.enabled:
|
||||||
|
frappe.throw(_("Helpdesk is not integrated with the CRM"))
|
||||||
|
|
||||||
|
if not helpdesk_crm_settings.is_helpdesk_in_different_site:
|
||||||
|
# from helpdesk.integrations.crm.api import create_customer
|
||||||
|
return create_customer(name, email)
|
||||||
|
|
||||||
|
|
||||||
|
def get_helpdesk_script():
|
||||||
|
return """class CRMDeal {
|
||||||
|
onLoad() {
|
||||||
|
this.actions.push(
|
||||||
|
{
|
||||||
|
group: "Helpdesk",
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Create customer in Helpdesk",
|
||||||
|
onClick: () => {
|
||||||
|
call('crm.fcrm.doctype.helpdesk_crm_settings.helpdesk_crm_settings.create_customer_in_helpdesk', {
|
||||||
|
name: this.doc.organization,
|
||||||
|
email: this.doc.email
|
||||||
|
}).then((a) => {
|
||||||
|
toast.success("Customer created successfully, " + a.customer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
|
||||||
|
# Helpdesk methods TODO: move to helpdesk.integrations.crm.api
|
||||||
|
def create_customer(name, email):
|
||||||
|
customer = frappe.db.exists("HD Customer", name)
|
||||||
|
if not customer:
|
||||||
|
customer = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "HD Customer",
|
||||||
|
"customer_name": name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
customer.insert(ignore_permissions=True, ignore_if_duplicate=True)
|
||||||
|
else:
|
||||||
|
customer = frappe.get_doc("HD Customer", customer)
|
||||||
|
|
||||||
|
contact = frappe.db.exists("Contact", {"email_id": email})
|
||||||
|
if contact:
|
||||||
|
contact = frappe.get_doc("Contact", contact)
|
||||||
|
contact.append(
|
||||||
|
"links", {"link_doctype": "HD Customer", "link_name": customer.name}
|
||||||
|
)
|
||||||
|
contact.save(ignore_permissions=True)
|
||||||
|
else:
|
||||||
|
contact = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Contact",
|
||||||
|
"first_name": email.split("@")[0],
|
||||||
|
"email_ids": [{"email_id": email, "is_primary": 1}],
|
||||||
|
"links": [{"link_doctype": "HD Customer", "link_name": customer.name}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
contact.insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
if not frappe.db.exists("User", contact.email_id):
|
||||||
|
invite_user(contact.name)
|
||||||
|
else:
|
||||||
|
base_url = frappe.utils.get_url() + "/helpdesk"
|
||||||
|
frappe.sendmail(
|
||||||
|
recipients=[contact.email_id],
|
||||||
|
subject="Welcome existing user to Helpdesk",
|
||||||
|
message=f"""
|
||||||
|
<h1>Hello,</h1>
|
||||||
|
<button>{base_url}</button>
|
||||||
|
""",
|
||||||
|
now=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"customer": customer.name, "contact": contact.name}
|
||||||
|
|
||||||
|
|
||||||
|
def invite_user(contact: str):
|
||||||
|
contact = frappe.get_doc("Contact", contact)
|
||||||
|
contact.check_permission()
|
||||||
|
|
||||||
|
if not contact.email_id:
|
||||||
|
frappe.throw(_("Please set Email Address"))
|
||||||
|
|
||||||
|
user = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "User",
|
||||||
|
"first_name": contact.first_name,
|
||||||
|
"last_name": contact.last_name,
|
||||||
|
"email": contact.email_id,
|
||||||
|
"user_type": "Website User",
|
||||||
|
"send_welcome_email": 0
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
|
||||||
|
contact.user = user.name
|
||||||
|
contact.save(ignore_permissions=True)
|
||||||
|
|
||||||
|
send_welcome_mail_to_user(user)
|
||||||
|
return user.name
|
||||||
|
|
||||||
|
|
||||||
|
def send_welcome_mail_to_user(user):
|
||||||
|
from frappe.utils import get_url
|
||||||
|
from frappe.utils.user import get_user_fullname
|
||||||
|
|
||||||
|
link = user.reset_password()
|
||||||
|
|
||||||
|
frappe.cache.hset("redirect_after_login", user.name, "/helpdesk")
|
||||||
|
|
||||||
|
site_url = get_url()
|
||||||
|
subject = _("Welcome to Helpdesk")
|
||||||
|
|
||||||
|
created_by = get_user_fullname(frappe.session["user"])
|
||||||
|
if created_by == "Guest":
|
||||||
|
created_by = "Administrator"
|
||||||
|
|
||||||
|
args = {
|
||||||
|
"first_name": user.first_name or user.last_name or "user",
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"user": user.name,
|
||||||
|
"title": subject,
|
||||||
|
"login_url": get_url(),
|
||||||
|
"created_by": created_by,
|
||||||
|
"site_url": site_url,
|
||||||
|
"link": link
|
||||||
|
}
|
||||||
|
|
||||||
|
frappe.sendmail(
|
||||||
|
recipients=[user.email],
|
||||||
|
subject=subject,
|
||||||
|
template="helpdesk_invitation",
|
||||||
|
args=args,
|
||||||
|
now=True,
|
||||||
|
)
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestHelpdeskCRMSettings(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for HelpdeskCRMSettings.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
65
crm/hooks.py
65
crm/hooks.py
@ -22,6 +22,8 @@ add_to_apps_screen = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export_python_type_annotations = True
|
||||||
|
|
||||||
# Includes in <head>
|
# Includes in <head>
|
||||||
# ------------------
|
# ------------------
|
||||||
|
|
||||||
@ -167,23 +169,28 @@ doc_events = {
|
|||||||
# Scheduled Tasks
|
# Scheduled Tasks
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
# scheduler_events = {
|
scheduler_events = {
|
||||||
# "all": [
|
"daily_long": [
|
||||||
# "crm.tasks.all"
|
"crm.tasks.sync_leads_from_sources_daily"
|
||||||
# ],
|
],
|
||||||
# "daily": [
|
"hourly_long": [
|
||||||
# "crm.tasks.daily"
|
"crm.tasks.sync_leads_from_sources_hourly"
|
||||||
# ],
|
],
|
||||||
# "hourly": [
|
"monthly_long": [
|
||||||
# "crm.tasks.hourly"
|
"crm.tasks.sync_leads_from_sources_monthly"
|
||||||
# ],
|
],
|
||||||
# "weekly": [
|
"cron": {
|
||||||
# "crm.tasks.weekly"
|
"*/5 * * * *": [
|
||||||
# ],
|
"crm.tasks.sync_leads_from_sources_5_minutes"
|
||||||
# "monthly": [
|
],
|
||||||
# "crm.tasks.monthly"
|
"*/10 * * * *": [
|
||||||
# ],
|
"crm.tasks.sync_leads_from_sources_10_minutes"
|
||||||
# }
|
],
|
||||||
|
"*/15 * * * *": [
|
||||||
|
"crm.tasks.sync_leads_from_sources_15_minutes"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
# -------
|
# -------
|
||||||
@ -264,22 +271,6 @@ standard_dropdown_items = [
|
|||||||
"route": "#",
|
"route": "#",
|
||||||
"is_standard": 1,
|
"is_standard": 1,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name1": "support_link",
|
|
||||||
"label": "Support",
|
|
||||||
"type": "Route",
|
|
||||||
"icon": "life-buoy",
|
|
||||||
"route": "https://t.me/frappecrm",
|
|
||||||
"is_standard": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name1": "docs_link",
|
|
||||||
"label": "Docs",
|
|
||||||
"type": "Route",
|
|
||||||
"icon": "book-open",
|
|
||||||
"route": "https://docs.frappe.io/crm",
|
|
||||||
"is_standard": 1,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name1": "toggle_theme",
|
"name1": "toggle_theme",
|
||||||
"label": "Toggle theme",
|
"label": "Toggle theme",
|
||||||
@ -303,6 +294,14 @@ standard_dropdown_items = [
|
|||||||
"route": "#",
|
"route": "#",
|
||||||
"is_standard": 1,
|
"is_standard": 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name1": "about",
|
||||||
|
"label": "About",
|
||||||
|
"type": "Route",
|
||||||
|
"icon": "info",
|
||||||
|
"route": "#",
|
||||||
|
"is_standard": 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name1": "separator",
|
"name1": "separator",
|
||||||
"label": "",
|
"label": "",
|
||||||
|
|||||||
149
crm/install.py
149
crm/install.py
@ -4,6 +4,9 @@ import click
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||||
|
|
||||||
|
from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard
|
||||||
|
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
|
||||||
|
|
||||||
|
|
||||||
def before_install():
|
def before_install():
|
||||||
pass
|
pass
|
||||||
@ -18,7 +21,12 @@ def after_install(force=False):
|
|||||||
add_email_template_custom_fields()
|
add_email_template_custom_fields()
|
||||||
add_default_industries()
|
add_default_industries()
|
||||||
add_default_lead_sources()
|
add_default_lead_sources()
|
||||||
|
add_default_lost_reasons()
|
||||||
add_standard_dropdown_items()
|
add_standard_dropdown_items()
|
||||||
|
add_default_scripts()
|
||||||
|
create_default_manager_dashboard(force)
|
||||||
|
create_assignment_rule_custom_fields()
|
||||||
|
add_assignment_rule_property_setters()
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
@ -65,30 +73,44 @@ def add_default_deal_statuses():
|
|||||||
statuses = {
|
statuses = {
|
||||||
"Qualification": {
|
"Qualification": {
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
|
"type": "Open",
|
||||||
|
"probability": 10,
|
||||||
"position": 1,
|
"position": 1,
|
||||||
},
|
},
|
||||||
"Demo/Making": {
|
"Demo/Making": {
|
||||||
"color": "orange",
|
"color": "orange",
|
||||||
|
"type": "Ongoing",
|
||||||
|
"probability": 25,
|
||||||
"position": 2,
|
"position": 2,
|
||||||
},
|
},
|
||||||
"Proposal/Quotation": {
|
"Proposal/Quotation": {
|
||||||
"color": "blue",
|
"color": "blue",
|
||||||
|
"type": "Ongoing",
|
||||||
|
"probability": 50,
|
||||||
"position": 3,
|
"position": 3,
|
||||||
},
|
},
|
||||||
"Negotiation": {
|
"Negotiation": {
|
||||||
"color": "yellow",
|
"color": "yellow",
|
||||||
|
"type": "Ongoing",
|
||||||
|
"probability": 70,
|
||||||
"position": 4,
|
"position": 4,
|
||||||
},
|
},
|
||||||
"Ready to Close": {
|
"Ready to Close": {
|
||||||
"color": "purple",
|
"color": "purple",
|
||||||
|
"type": "Ongoing",
|
||||||
|
"probability": 90,
|
||||||
"position": 5,
|
"position": 5,
|
||||||
},
|
},
|
||||||
"Won": {
|
"Won": {
|
||||||
"color": "green",
|
"color": "green",
|
||||||
|
"type": "Won",
|
||||||
|
"probability": 100,
|
||||||
"position": 6,
|
"position": 6,
|
||||||
},
|
},
|
||||||
"Lost": {
|
"Lost": {
|
||||||
"color": "red",
|
"color": "red",
|
||||||
|
"type": "Lost",
|
||||||
|
"probability": 0,
|
||||||
"position": 7,
|
"position": 7,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -100,6 +122,8 @@ def add_default_deal_statuses():
|
|||||||
doc = frappe.new_doc("CRM Deal Status")
|
doc = frappe.new_doc("CRM Deal Status")
|
||||||
doc.deal_status = status
|
doc.deal_status = status
|
||||||
doc.color = statuses[status]["color"]
|
doc.color = statuses[status]["color"]
|
||||||
|
doc.type = statuses[status]["type"]
|
||||||
|
doc.probability = statuses[status]["probability"]
|
||||||
doc.position = statuses[status]["position"]
|
doc.position = statuses[status]["position"]
|
||||||
doc.insert()
|
doc.insert()
|
||||||
|
|
||||||
@ -170,7 +194,7 @@ def add_default_fields_layout(force=False):
|
|||||||
},
|
},
|
||||||
"CRM Deal-Data Fields": {
|
"CRM Deal-Data Fields": {
|
||||||
"doctype": "CRM Deal",
|
"doctype": "CRM Deal",
|
||||||
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_z9XL", "fields": ["organization", "annual_revenue", "next_step"]}, {"name": "column_gM4w", "fields": ["website", "close_date", "deal_owner"]}, {"name": "column_gWmE", "fields": ["territory", "probability"]}]}]',
|
"layout": '[{"name":"first_tab","sections":[{"label":"Details","name":"details_section","opened":true,"columns":[{"name":"column_z9XL","fields":["organization","annual_revenue","next_step"]},{"name":"column_gM4w","fields":["website","closed_date","deal_owner"]},{"name":"column_gWmE","fields":["territory","probability"]}]},{"label":"Products","name":"section_jHhQ","opened":true,"columns":[{"name":"column_xiNF","fields":["products"]}],"editingLabel":false,"hideLabel":true},{"label":"New Section","name":"section_WNOQ","opened":true,"columns":[{"name":"column_ziBW","fields":["total"]},{"label":"","name":"column_wuwA","fields":["net_total"]}],"hideBorder":true,"hideLabel":true}]}]',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,6 +364,44 @@ def add_default_lead_sources():
|
|||||||
doc.insert()
|
doc.insert()
|
||||||
|
|
||||||
|
|
||||||
|
def add_default_lost_reasons():
|
||||||
|
lost_reasons = [
|
||||||
|
{
|
||||||
|
"reason": "Pricing",
|
||||||
|
"description": "The prospect found the pricing to be too high or not competitive.",
|
||||||
|
},
|
||||||
|
{"reason": "Competition", "description": "The prospect chose a competitor's product or service."},
|
||||||
|
{
|
||||||
|
"reason": "Budget Constraints",
|
||||||
|
"description": "The prospect did not have the budget to proceed with the purchase.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "Missing Features",
|
||||||
|
"description": "The prospect felt that the product or service was missing key features they needed.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "Long Sales Cycle",
|
||||||
|
"description": "The sales process took too long, leading to loss of interest.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "No Decision-Maker",
|
||||||
|
"description": "The prospect was not the decision-maker and could not proceed.",
|
||||||
|
},
|
||||||
|
{"reason": "Unresponsive Prospect", "description": "The prospect did not respond to follow-ups."},
|
||||||
|
{"reason": "Poor Fit", "description": "The prospect was not a good fit for the product or service."},
|
||||||
|
{"reason": "Other", "description": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
for reason in lost_reasons:
|
||||||
|
if frappe.db.exists("CRM Lost Reason", reason["reason"]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc = frappe.new_doc("CRM Lost Reason")
|
||||||
|
doc.lost_reason = reason["reason"]
|
||||||
|
doc.description = reason["description"]
|
||||||
|
doc.insert()
|
||||||
|
|
||||||
|
|
||||||
def add_standard_dropdown_items():
|
def add_standard_dropdown_items():
|
||||||
crm_settings = frappe.get_single("FCRM Settings")
|
crm_settings = frappe.get_single("FCRM Settings")
|
||||||
|
|
||||||
@ -353,3 +415,88 @@ def add_standard_dropdown_items():
|
|||||||
crm_settings.append("dropdown_items", item)
|
crm_settings.append("dropdown_items", item)
|
||||||
|
|
||||||
crm_settings.save()
|
crm_settings.save()
|
||||||
|
|
||||||
|
|
||||||
|
def add_default_scripts():
|
||||||
|
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import create_forecasting_script
|
||||||
|
|
||||||
|
for doctype in ["CRM Lead", "CRM Deal"]:
|
||||||
|
create_product_details_script(doctype)
|
||||||
|
create_forecasting_script()
|
||||||
|
|
||||||
|
|
||||||
|
def add_assignment_rule_property_setters():
|
||||||
|
"""Add a property setter to the Assignment Rule DocType for assign_condition and unassign_condition."""
|
||||||
|
|
||||||
|
default_fields = {
|
||||||
|
"doctype": "Property Setter",
|
||||||
|
"doctype_or_field": "DocField",
|
||||||
|
"doc_type": "Assignment Rule",
|
||||||
|
"property_type": "Data",
|
||||||
|
"is_system_generated": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-assign_condition-depends_on"}):
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
**default_fields,
|
||||||
|
"name": "Assignment Rule-assign_condition-depends_on",
|
||||||
|
"field_name": "assign_condition",
|
||||||
|
"property": "depends_on",
|
||||||
|
"value": "eval: !doc.assign_condition_json",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
else:
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Property Setter",
|
||||||
|
{"name": "Assignment Rule-assign_condition-depends_on"},
|
||||||
|
"value",
|
||||||
|
"eval: !doc.assign_condition_json",
|
||||||
|
)
|
||||||
|
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-unassign_condition-depends_on"}):
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
**default_fields,
|
||||||
|
"name": "Assignment Rule-unassign_condition-depends_on",
|
||||||
|
"field_name": "unassign_condition",
|
||||||
|
"property": "depends_on",
|
||||||
|
"value": "eval: !doc.unassign_condition_json",
|
||||||
|
}
|
||||||
|
).insert()
|
||||||
|
else:
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Property Setter",
|
||||||
|
{"name": "Assignment Rule-unassign_condition-depends_on"},
|
||||||
|
"value",
|
||||||
|
"eval: !doc.unassign_condition_json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_assignment_rule_custom_fields():
|
||||||
|
if not frappe.get_meta("Assignment Rule").has_field("assign_condition_json"):
|
||||||
|
click.secho("* Installing Custom Fields in Assignment Rule")
|
||||||
|
|
||||||
|
create_custom_fields(
|
||||||
|
{
|
||||||
|
"Assignment Rule": [
|
||||||
|
{
|
||||||
|
"description": "Autogenerated field by CRM App",
|
||||||
|
"fieldname": "assign_condition_json",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Assign Condition JSON",
|
||||||
|
"insert_after": "assign_condition",
|
||||||
|
"depends_on": "eval: doc.assign_condition_json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Autogenerated field by CRM App",
|
||||||
|
"fieldname": "unassign_condition_json",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Unassign Condition JSON",
|
||||||
|
"insert_after": "unassign_condition",
|
||||||
|
"depends_on": "eval: doc.unassign_condition_json",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
frappe.clear_cache(doctype="Assignment Rule")
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def set_default_calling_medium(medium):
|
|||||||
frappe.get_doc(
|
frappe.get_doc(
|
||||||
{
|
{
|
||||||
"doctype": "CRM Telephony Agent",
|
"doctype": "CRM Telephony Agent",
|
||||||
"agent": frappe.session.user,
|
"user": frappe.session.user,
|
||||||
"default_medium": medium,
|
"default_medium": medium,
|
||||||
}
|
}
|
||||||
).insert(ignore_permissions=True)
|
).insert(ignore_permissions=True)
|
||||||
@ -110,12 +110,12 @@ def get_contact_by_phone_number(phone_number):
|
|||||||
number = parse_phone_number(phone_number)
|
number = parse_phone_number(phone_number)
|
||||||
|
|
||||||
if number.get("is_valid"):
|
if number.get("is_valid"):
|
||||||
return get_contact(number.get("national_number"))
|
return get_contact(number.get("national_number"), number.get("country"))
|
||||||
else:
|
else:
|
||||||
return get_contact(phone_number, exact_match=True)
|
return get_contact(phone_number, number.get("country"), exact_match=True)
|
||||||
|
|
||||||
|
|
||||||
def get_contact(phone_number, exact_match=False):
|
def get_contact(phone_number, country="IN", exact_match=False):
|
||||||
if not phone_number:
|
if not phone_number:
|
||||||
return {"mobile_no": phone_number}
|
return {"mobile_no": phone_number}
|
||||||
|
|
||||||
@ -149,11 +149,11 @@ def get_contact(phone_number, exact_match=False):
|
|||||||
deal = frappe.db.get_value(
|
deal = frappe.db.get_value(
|
||||||
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
|
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
|
||||||
)
|
)
|
||||||
if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match):
|
if are_same_phone_number(contact.mobile_no, phone_number, country, validate=not exact_match):
|
||||||
contact["deal"] = deal
|
contact["deal"] = deal
|
||||||
return contact
|
return contact
|
||||||
# Else, return the first contact
|
# Else, return the first contact
|
||||||
if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match):
|
if are_same_phone_number(contacts[0].mobile_no, phone_number, country, validate=not exact_match):
|
||||||
return contacts[0]
|
return contacts[0]
|
||||||
|
|
||||||
# Else, Check if the number is associated with a lead
|
# Else, Check if the number is associated with a lead
|
||||||
@ -173,7 +173,7 @@ def get_contact(phone_number, exact_match=False):
|
|||||||
|
|
||||||
if len(leads):
|
if len(leads):
|
||||||
for lead in leads:
|
for lead in leads:
|
||||||
if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match):
|
if are_same_phone_number(lead.mobile_no, phone_number, country, validate=not exact_match):
|
||||||
lead["lead"] = lead.name
|
lead["lead"] = lead.name
|
||||||
lead["full_name"] = lead.lead_name
|
lead["full_name"] = lead.lead_name
|
||||||
return lead
|
return lead
|
||||||
|
|||||||
@ -242,19 +242,18 @@ def get_call_log_status(call_payload, direction="inbound"):
|
|||||||
elif status == "failed":
|
elif status == "failed":
|
||||||
return "Failed"
|
return "Failed"
|
||||||
|
|
||||||
status = call_payload.get("DialCallStatus")
|
|
||||||
call_type = call_payload.get("CallType")
|
call_type = call_payload.get("CallType")
|
||||||
dial_call_status = call_payload.get("DialCallStatus")
|
status = call_payload.get("DialCallStatus") or call_payload.get("Status")
|
||||||
|
|
||||||
if call_type == "incomplete" and dial_call_status == "no-answer":
|
if call_type == "incomplete" and status == "no-answer":
|
||||||
status = "No Answer"
|
status = "No Answer"
|
||||||
elif call_type == "client-hangup" and dial_call_status == "canceled":
|
elif call_type == "client-hangup" and status == "canceled":
|
||||||
status = "Canceled"
|
status = "Canceled"
|
||||||
elif call_type == "incomplete" and dial_call_status == "failed":
|
elif call_type == "incomplete" and status == "failed":
|
||||||
status = "Failed"
|
status = "Failed"
|
||||||
elif call_type == "completed":
|
elif call_type == "completed":
|
||||||
status = "Completed"
|
status = "Completed"
|
||||||
elif dial_call_status == "busy":
|
elif status == "busy":
|
||||||
status = "Ringing"
|
status = "Ringing"
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|||||||
0
crm/lead_syncing/__init__.py
Normal file
0
crm/lead_syncing/__init__.py
Normal file
0
crm/lead_syncing/doctype/__init__.py
Normal file
0
crm/lead_syncing/doctype/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on("Facebook Lead Form", {
|
||||||
|
refresh(frm) {
|
||||||
|
//
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "field:id",
|
||||||
|
"creation": "2025-09-26 19:01:48.325681",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"page",
|
||||||
|
"id",
|
||||||
|
"column_break_ahyo",
|
||||||
|
"form_name",
|
||||||
|
"section_break_iqhq",
|
||||||
|
"questions"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "page",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Page",
|
||||||
|
"options": "Facebook Page",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "ID",
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ahyo",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "form_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Form Name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_iqhq",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "questions",
|
||||||
|
"fieldtype": "Table",
|
||||||
|
"label": "Questions",
|
||||||
|
"options": "Facebook Lead Form Question"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"in_create": 1,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-09-29 18:50:19.215513",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Lead Syncing",
|
||||||
|
"name": "Facebook Lead Form",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "hussain@frappe.io",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"show_title_field_in_link": 1,
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"title_field": "form_name"
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookLeadForm(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from crm.lead_syncing.doctype.facebook_lead_form_question.facebook_lead_form_question import FacebookLeadFormQuestion
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
form_name: DF.Data | None
|
||||||
|
id: DF.Data | None
|
||||||
|
page: DF.Link
|
||||||
|
questions: DF.Table[FacebookLeadFormQuestion]
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
pass
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestFacebookLeadForm(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for FacebookLeadForm.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2025-09-26 19:45:47.696180",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"label",
|
||||||
|
"id",
|
||||||
|
"column_break_hgde",
|
||||||
|
"key",
|
||||||
|
"type",
|
||||||
|
"mapped_to_crm_field"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "label",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_hgde",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "key",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Key",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "type",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "Not Synced",
|
||||||
|
"fieldname": "mapped_to_crm_field",
|
||||||
|
"fieldtype": "Autocomplete",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Mapped to CRM Field"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"istable": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-09-29 18:45:21.800960",
|
||||||
|
"modified_by": "hussain@frappe.io",
|
||||||
|
"module": "Lead Syncing",
|
||||||
|
"name": "Facebook Lead Form Question",
|
||||||
|
"owner": "hussain@frappe.io",
|
||||||
|
"permissions": [],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookLeadFormQuestion(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
id: DF.Data | None
|
||||||
|
key: DF.Data
|
||||||
|
label: DF.Data | None
|
||||||
|
mapped_to_crm_field: DF.Autocomplete | None
|
||||||
|
parent: DF.Data
|
||||||
|
parentfield: DF.Data
|
||||||
|
parenttype: DF.Data
|
||||||
|
type: DF.Data | None
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
pass
|
||||||
0
crm/lead_syncing/doctype/facebook_page/__init__.py
Normal file
0
crm/lead_syncing/doctype/facebook_page/__init__.py
Normal file
8
crm/lead_syncing/doctype/facebook_page/facebook_page.js
Normal file
8
crm/lead_syncing/doctype/facebook_page/facebook_page.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("Facebook Page", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
83
crm/lead_syncing/doctype/facebook_page/facebook_page.json
Normal file
83
crm/lead_syncing/doctype/facebook_page/facebook_page.json
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"autoname": "field:id",
|
||||||
|
"creation": "2025-09-26 18:59:12.833879",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"page_name",
|
||||||
|
"account_id",
|
||||||
|
"category",
|
||||||
|
"column_break_eteo",
|
||||||
|
"id",
|
||||||
|
"access_token"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "category",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Category"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "ID",
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "account_id",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Account ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_eteo",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "access_token",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"label": "Access Token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "page_name",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"label": "Page Name"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"in_create": 1,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"link_doctype": "Facebook Lead Form",
|
||||||
|
"link_fieldname": "page"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modified": "2025-09-26 19:36:59.413214",
|
||||||
|
"modified_by": "hussain@frappe.io",
|
||||||
|
"module": "Lead Syncing",
|
||||||
|
"name": "Facebook Page",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "hussain@frappe.io",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"show_title_field_in_link": 1,
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"title_field": "page_name",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
24
crm/lead_syncing/doctype/facebook_page/facebook_page.py
Normal file
24
crm/lead_syncing/doctype/facebook_page/facebook_page.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookPage(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
access_token: DF.SmallText | None
|
||||||
|
account_id: DF.Data | None
|
||||||
|
category: DF.Data | None
|
||||||
|
id: DF.Data | None
|
||||||
|
page_name: DF.Data | None
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
pass
|
||||||
22
crm/lead_syncing/doctype/facebook_page/test_facebook_page.py
Normal file
22
crm/lead_syncing/doctype/facebook_page/test_facebook_page.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestFacebookPage(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for FacebookPage.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("Failed Lead Sync Log", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"creation": "2025-10-19 17:29:10.261307",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"type",
|
||||||
|
"column_break_dhay",
|
||||||
|
"source",
|
||||||
|
"section_break_fhot",
|
||||||
|
"lead_data",
|
||||||
|
"section_break_knec",
|
||||||
|
"traceback"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"default": "Failure",
|
||||||
|
"fieldname": "type",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Type",
|
||||||
|
"options": "Duplicate\nFailure",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "lead_data",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Lead Data",
|
||||||
|
"options": "JSON",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_dhay",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "source",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Source",
|
||||||
|
"options": "Lead Sync Source",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_fhot",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_break_knec",
|
||||||
|
"fieldtype": "Section Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "traceback",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Traceback",
|
||||||
|
"read_only": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"in_create": 1,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-10-19 18:59:17.152547",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Lead Syncing",
|
||||||
|
"name": "Failed Lead Sync Log",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"rows_threshold_for_grid_search": 20,
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class FailedLeadSyncLog(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
lead_data: DF.Code | None
|
||||||
|
source: DF.Link | None
|
||||||
|
traceback: DF.Code | None
|
||||||
|
type: DF.Literal["Duplicate", "Failure"]
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
pass
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestFailedLeadSyncLog(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for FailedLeadSyncLog.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
214
crm/lead_syncing/doctype/lead_sync_source/facebook.py
Normal file
214
crm/lead_syncing/doctype/lead_sync_source/facebook.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe.exceptions import ValidationError
|
||||||
|
from frappe.integrations.utils import make_get_request
|
||||||
|
|
||||||
|
FB_GRAPH_API_BASE = "https://graph.facebook.com"
|
||||||
|
FB_GRAPH_API_VERSION = "v23.0"
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateLeadError(ValidationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_fb_graph_api_url(endpoint: str) -> str:
|
||||||
|
if endpoint.startswith("/"):
|
||||||
|
endpoint = endpoint[1:]
|
||||||
|
|
||||||
|
return f"{FB_GRAPH_API_BASE}/{FB_GRAPH_API_VERSION}/{endpoint}"
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookSyncSource:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
access_token: str,
|
||||||
|
form_id: str,
|
||||||
|
source_name: str | None = None,
|
||||||
|
):
|
||||||
|
self.access_token = access_token
|
||||||
|
self.form_id = form_id
|
||||||
|
self.source_name = source_name
|
||||||
|
|
||||||
|
def get_api_url(self, endpoint: str) -> str:
|
||||||
|
return get_fb_graph_api_url(endpoint)
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
leads = self.fetch_leads()
|
||||||
|
question_to_field_map = self.get_form_questions_mapping()
|
||||||
|
|
||||||
|
for lead in leads:
|
||||||
|
lead_data = {item["name"]: item["values"][0] for item in lead["field_data"]}
|
||||||
|
crm_lead_data = {
|
||||||
|
question_to_field_map.get(k): v for k, v in lead_data.items() if k in question_to_field_map
|
||||||
|
}
|
||||||
|
crm_lead_data["source"] = "Facebook"
|
||||||
|
crm_lead_data["facebook_lead_id"] = lead["id"]
|
||||||
|
crm_lead_data["facebook_form_id"] = self.form_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.validate_duplicate_lead(crm_lead_data, question_to_field_map)
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "CRM Lead",
|
||||||
|
**crm_lead_data,
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
except (frappe.UniqueValidationError, DuplicateLeadError):
|
||||||
|
self.create_failure_log(lead, "Duplicate")
|
||||||
|
except Exception:
|
||||||
|
self.create_failure_log(lead, traceback=frappe.get_traceback(with_context=True))
|
||||||
|
|
||||||
|
self.update_last_synced_at()
|
||||||
|
|
||||||
|
def fetch_leads(self):
|
||||||
|
url = self.get_api_url(f"/{self.form_id}/leads")
|
||||||
|
|
||||||
|
if self.last_synced_at:
|
||||||
|
timestamp = frappe.utils.data.get_timestamp(self.last_synced_at)
|
||||||
|
filtering = (
|
||||||
|
f"filtering=[{{'field':'time_created','operator':'GREATER_THAN','value':{timestamp}}}]"
|
||||||
|
)
|
||||||
|
url = f"{url}?{filtering}"
|
||||||
|
|
||||||
|
return make_get_request(
|
||||||
|
url,
|
||||||
|
params={
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"fields": "id,created_time,field_data",
|
||||||
|
"limit": 100000, # TODO: pagination
|
||||||
|
},
|
||||||
|
).get("data", [])
|
||||||
|
|
||||||
|
def get_form_questions_mapping(self):
|
||||||
|
form_questions = frappe.db.get_all(
|
||||||
|
"Facebook Lead Form Question",
|
||||||
|
filters={"parent": self.form_id},
|
||||||
|
fields=["key", "mapped_to_crm_field"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {q["key"]: q["mapped_to_crm_field"] for q in form_questions if q["mapped_to_crm_field"]}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_synced_at(self):
|
||||||
|
return frappe.db.get_value(
|
||||||
|
"Lead Sync Source", self.source_name or {"facebook_lead_form": self.form_id}, "last_synced_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_failure_log(
|
||||||
|
self, lead_data: dict | None = None, type: str = "Failure", traceback: str | None = None
|
||||||
|
):
|
||||||
|
return frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Failed Lead Sync Log",
|
||||||
|
"type": type,
|
||||||
|
"lead_data": frappe.as_json(lead_data),
|
||||||
|
"source": self.get_source_name(),
|
||||||
|
"traceback": traceback,
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
def update_last_synced_at(self):
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Lead Sync Source",
|
||||||
|
self.source_name or {"facebook_lead_form": self.form_id},
|
||||||
|
"last_synced_at",
|
||||||
|
frappe.utils.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_source_name(self):
|
||||||
|
if self.source_name:
|
||||||
|
return self.source_name
|
||||||
|
|
||||||
|
return frappe.db.get_value("Lead Sync Source", {"facebook_lead_form": self.form_id}, "name")
|
||||||
|
|
||||||
|
def validate_duplicate_lead(self, lead_data: dict, field_mapping: dict):
|
||||||
|
validation_filters = {crm_field: lead_data[crm_field] for crm_field in field_mapping.values()}
|
||||||
|
validation_filters["facebook_form_id"] = lead_data["facebook_form_id"] # only for this campaign
|
||||||
|
if frappe.db.exists("CRM Lead", validation_filters):
|
||||||
|
raise DuplicateLeadError
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def fetch_and_store_pages_from_facebook(access_token: str) -> list[dict]:
|
||||||
|
if not access_token:
|
||||||
|
frappe.throw(frappe._("Access token is required"))
|
||||||
|
|
||||||
|
account_details = get_fb_account_details(access_token)
|
||||||
|
if not account_details.get("id"):
|
||||||
|
frappe.throw(frappe._("Invalid access token provided for Facebook."))
|
||||||
|
|
||||||
|
url = get_fb_graph_api_url("/me/accounts")
|
||||||
|
pages = make_get_request(url, params={"access_token": access_token}).get("data", [])
|
||||||
|
for page in pages:
|
||||||
|
page_id = page["id"]
|
||||||
|
already_synced = frappe.db.exists("Facebook Page", page_id)
|
||||||
|
if not already_synced:
|
||||||
|
create_facebook_page_in_db(page, account_details)
|
||||||
|
forms = fetch_and_store_leadgen_forms_from_facebook(page_id, page["access_token"])
|
||||||
|
page["forms"] = forms
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def get_fb_account_details(access_token: str) -> dict:
|
||||||
|
url = get_fb_graph_api_url("me")
|
||||||
|
try:
|
||||||
|
response = make_get_request(url, params={"access_token": access_token})
|
||||||
|
except Exception as _:
|
||||||
|
frappe.throw(frappe._("Please check your access token"))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def create_facebook_page_in_db(page: dict, account_details: dict) -> None:
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Facebook Page",
|
||||||
|
"page_name": page["name"],
|
||||||
|
"id": page["id"],
|
||||||
|
"category": page["category"],
|
||||||
|
"access_token": page["access_token"],
|
||||||
|
"account_id": account_details["id"],
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_and_store_leadgen_forms_from_facebook(page_id: str, page_access_token: str) -> list[dict]:
|
||||||
|
fields = "id,name,questions"
|
||||||
|
url = get_fb_graph_api_url(f"/{page_id}/leadgen_forms")
|
||||||
|
forms = make_get_request(
|
||||||
|
url,
|
||||||
|
params={
|
||||||
|
"access_token": page_access_token,
|
||||||
|
"fields": fields,
|
||||||
|
"limit": 15000,
|
||||||
|
},
|
||||||
|
).get("data", [])
|
||||||
|
for form in forms:
|
||||||
|
form_id = form["id"]
|
||||||
|
already_synced = frappe.db.exists("Facebook Lead Form", form_id)
|
||||||
|
if already_synced:
|
||||||
|
continue
|
||||||
|
create_facebook_lead_form_in_db(form, page_id)
|
||||||
|
|
||||||
|
return forms
|
||||||
|
|
||||||
|
|
||||||
|
def create_facebook_lead_form_in_db(form: dict, page_id: str) -> None:
|
||||||
|
form_doc = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Facebook Lead Form",
|
||||||
|
"form_name": form["name"],
|
||||||
|
"id": form["id"],
|
||||||
|
"page": page_id,
|
||||||
|
"questions": form["questions"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
form_doc.insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_pages_with_forms() -> list[dict]:
|
||||||
|
pages = frappe.db.get_all("Facebook Page", fields=["id", "name"])
|
||||||
|
for page in pages:
|
||||||
|
forms = frappe.db.get_all("Facebook Lead Form", filters={"page": page["id"]}, fields=["id", "name"])
|
||||||
|
page["forms"] = forms
|
||||||
|
return pages
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on("Lead Sync Source", {
|
||||||
|
refresh(frm) {
|
||||||
|
frm.add_custom_button(__('Sync Now'), () => {
|
||||||
|
frm.call("sync_leads").then(() => {
|
||||||
|
frappe.msgprint(__('Lead sync initiated.'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
118
crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json
Normal file
118
crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "prompt",
|
||||||
|
"creation": "2025-09-26 18:51:41.145560",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"type",
|
||||||
|
"access_token",
|
||||||
|
"column_break_lwcw",
|
||||||
|
"last_synced_at",
|
||||||
|
"enabled",
|
||||||
|
"background_sync_frequency",
|
||||||
|
"facebook_section",
|
||||||
|
"facebook_page",
|
||||||
|
"column_break_zukm",
|
||||||
|
"facebook_lead_form"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"default": "Facebook",
|
||||||
|
"fieldname": "type",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Type",
|
||||||
|
"options": "Facebook",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_lwcw",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "last_synced_at",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "Last Synced At",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "access_token",
|
||||||
|
"fieldtype": "Password",
|
||||||
|
"label": "Access Token",
|
||||||
|
"length": 500,
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "facebook_page",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Facebook Page",
|
||||||
|
"options": "Facebook Page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "facebook_lead_form",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Facebook Lead Form",
|
||||||
|
"options": "Facebook Lead Form",
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"fieldname": "enabled",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Enabled?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:doc.type===\"Facebook\"",
|
||||||
|
"fieldname": "facebook_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Facebook"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_zukm",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "Hourly",
|
||||||
|
"fieldname": "background_sync_frequency",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "Background Sync Frequency",
|
||||||
|
"options": "Every 5 Minutes\nEvery 10 Minutes\nEvery 15 Minutes\nHourly\nDaily\nMonthly",
|
||||||
|
"reqd": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"link_doctype": "Failed Lead Sync Log",
|
||||||
|
"link_fieldname": "source"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modified": "2025-10-19 18:57:54.288252",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Lead Syncing",
|
||||||
|
"name": "Lead Sync Source",
|
||||||
|
"naming_rule": "Set by user",
|
||||||
|
"owner": "hussain@frappe.io",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": [],
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
from crm.lead_syncing.doctype.lead_sync_source.facebook import (
|
||||||
|
FacebookSyncSource,
|
||||||
|
fetch_and_store_pages_from_facebook,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LeadSyncSource(Document):
|
||||||
|
# begin: auto-generated types
|
||||||
|
# This code is auto-generated. Do not modify anything in this block.
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from frappe.types import DF
|
||||||
|
|
||||||
|
access_token: DF.Password
|
||||||
|
background_sync_frequency: DF.Literal["Every 5 Minutes", "Every 10 Minutes", "Every 15 Minutes", "Hourly", "Daily", "Monthly"]
|
||||||
|
enabled: DF.Check
|
||||||
|
facebook_lead_form: DF.Link | None
|
||||||
|
facebook_page: DF.Link | None
|
||||||
|
last_synced_at: DF.Datetime | None
|
||||||
|
type: DF.Literal["Facebook"]
|
||||||
|
# end: auto-generated types
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
self.validate_same_fb_form_active()
|
||||||
|
|
||||||
|
def validate_same_fb_form_active(self):
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.facebook_lead_form:
|
||||||
|
return
|
||||||
|
|
||||||
|
already_active = frappe.db.exists(
|
||||||
|
"Lead Sync Source",
|
||||||
|
{"enabled": 1, "facebook_lead_form": self.facebook_lead_form, "name": ["!=", self.name]},
|
||||||
|
)
|
||||||
|
|
||||||
|
if already_active:
|
||||||
|
frappe.throw(frappe._("A lead sync source is already enabled for this Facebook Lead Form!"))
|
||||||
|
|
||||||
|
def before_insert(self):
|
||||||
|
if self.type == "Facebook" and self.access_token:
|
||||||
|
fetch_and_store_pages_from_facebook(self.access_token)
|
||||||
|
# rest of the source types can be added here
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def sync_leads(self):
|
||||||
|
self._sync_leads()
|
||||||
|
# frappe.enqueue_doc(self.doctype, self.name, "_sync_leads", queue="long")
|
||||||
|
|
||||||
|
def _sync_leads(self):
|
||||||
|
if self.type == "Facebook" and self.access_token:
|
||||||
|
if not self.facebook_lead_form:
|
||||||
|
frappe.throw(frappe._("Please select a lead gen form before syncing!"))
|
||||||
|
|
||||||
|
FacebookSyncSource(
|
||||||
|
self.get_password("access_token"),
|
||||||
|
self.facebook_lead_form
|
||||||
|
).sync()
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestLeadSyncSource(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for LeadSyncSource.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
3731
crm/locale/ar.po
3731
crm/locale/ar.po
File diff suppressed because it is too large
Load Diff
3791
crm/locale/bs.po
3791
crm/locale/bs.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/cs.po
Normal file
6392
crm/locale/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
6392
crm/locale/da.po
Normal file
6392
crm/locale/da.po
Normal file
File diff suppressed because it is too large
Load Diff
4095
crm/locale/de.po
4095
crm/locale/de.po
File diff suppressed because it is too large
Load Diff
3753
crm/locale/eo.po
3753
crm/locale/eo.po
File diff suppressed because it is too large
Load Diff
3735
crm/locale/es.po
3735
crm/locale/es.po
File diff suppressed because it is too large
Load Diff
3949
crm/locale/fa.po
3949
crm/locale/fa.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user