From f649b3c4f1476552cb6e6c0d4011261b5cc4d206 Mon Sep 17 00:00:00 2001 From: HotSwapp <47397945+HotSwapp@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:30:50 -0500 Subject: [PATCH] reports: add PDF generation infra (fpdf2); Phone Book CSV/PDF export; Payments - Detailed report with preview and PDF grouped by deposit date; update Dockerfile for deps; smoke-tested in Docker --- Dockerfile | 2 + app/__pycache__/main.cpython-313.pyc | Bin 112351 -> 116647 bytes app/__pycache__/reporting.cpython-313.pyc | Bin 0 -> 9513 bytes app/main.py | 102 ++++++++++++- app/reporting.py | 166 ++++++++++++++++++++++ app/templates/payments_detailed.html | 73 ++++++++++ app/templates/report_phone_book.html | 3 + requirements.txt | 1 + 8 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 app/__pycache__/reporting.cpython-313.pyc create mode 100644 app/reporting.py create mode 100644 app/templates/payments_detailed.html diff --git a/Dockerfile b/Dockerfile index 8a58c15..9a38e3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,8 @@ WORKDIR /app RUN apt-get update && apt-get install -y \ gcc \ curl \ + libjpeg62-turbo \ + libfreetype6 \ && rm -rf /var/lib/apt/lists/* # Copy requirements first for better layer caching diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc index 11e33a9d9fefd1f9b212623cd377c11a516932f4..3b445a0abf7de6c83ac29122d5fa67e08d54f505 100644 GIT binary patch delta 21652 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 TJ=KQv-QPLJYEx1cfPnuCTWvhK delta 19116 zcmb_@3w%`7wfF2vX7UasgjaYaJOaa83?=!c%f();u?HB}dA z1Evb~QZ8{X8Kasuf1%ChzHGQn8<_452zNskfphdTqF>ytr4i&Z_edI0tqqzk(ec+O{q~&7)l5t{@u2wGrA#_X=&) z^n7`x`^u$LYuyVSjk`t1BPnV-tkQ@vBGXMB$~l_?av3xP#+&g1v1wewT&y{Y%p;@r_} zcKA{q)A_w$yTDziUC4tltu@gM?ho|XKa=}gdhDOY{cE&%PZM0k2y1&JFq`{>J@#MB z{p+~@J0!r_LKJFqs%>#n?|SVLcX3L+4Q6Yu*_vmz=9{erW~6D4?*hne@CRrR!p?BZtE{WZ@9TK)+{pQLn9OTJHYD!#0w! z|2R8S-A=)%AG?>b^))%vc9@!pLo@6dJBSm7eQgf*wP~rv&Kx~nmxH-8jk)-GlQ-z+ z25lGXCtCxGS9s)i-PiHlh1!id6t6cqy$N%hUP+wN;;XJN)V`N9;s$fX%{`2`C1=Dg zbHwf*MtnbK#COwj#`k2&Y1eKw{lKp6HQV3TCmcCGaa*_EQL5=_eoB=~$Q#oPjkrC{ z5J=IIxC5x>lDH|2C+>u(BNC+i^s;bj`_g1nRKI5`<}Q6l*~NNi=`{V`^74y|c+B0z z+&%RB1Nyy}e($5-PWt^J{qCn%p|X+yi(KJy+VFWb8dI&CNq3$-$H~#oAAD=zKp-M;v0;!Ki=9&}pIe zaL$N5G=f85{HNy3Dun#ZbQimp*?uJB{i7M}$ILd|E13#Ek5l`w_Hzn9*^8}^ZPrDS zsBrT{quG4YY{6`*yZg9QsXdh=&aG($jCeXNPRj9q0Zh~J_L^dwcq($HN2TKEGp0~> z?U&rn7DGkNXDPm^EjgFl=S&IUe#P369DZ-B*M4Q9JWs9vm6-@=FHm|=#tBxT_9BK# z-DLbF_ag1(l$PzF%3`J=8Z|2#SawS98redr z=(vcM-*0o|zugq)xO;JiIIo$^*tOqfw11z`em$f8Mn?P1jP_e*`y0fMYbQ+9x4V_U zls(pzUo%X*y<@g4*L~M~_nz5$-)x<&rV%i{($nJl!P?0jJ>6mI>6E)ELr))=Oxv{& zGunU1X#ZMXDigwXH$KR_U9aZ-nqu^`b)B^?`!sC^?+qU%+ezRzvwfQ zHX2vHksl3&T8unzTbrDzZ?C>eT&$m{9xNJ?f2*El@6!aNLaEUoBfrfXTT4iDhl~~9 z(Kiek&}$jJj^-1np&!|x?;BDl7U<6oIpqX#d69nq&`X^cqj{D7`OxvzmjEB9QFBWD(^pyo1Ho@~;e8ab%{ zdUS*US_!!9670&b{+E%X&bI&@fHbQA49j~Il@@fDNz-j<50BFdo0Q>;Jm#iw* z;Tk*wU}&@tfnB!0+8vI4#OBo6;_ zIKW5(q)y-(PJgqfU527|dVrPjt}qmAA&I?Vc{4rUuD?C@6>+nEVBCt~OX&IYf=Qvs znn-(0y6MR!NPPjH48%O^16$;Ri%^j03$e^EcwnMCt3{Z#!ZXB5px^pdHw#cqAk)P@>n zv1+3?6!d#z0go@b(X%$-^#^2BzCgHtN%4>V8bSxXml00S~Cl-xFGmK5Y7};+X!(^v|w-nVy@BN`=u9 zig;t57H=>V@XJ@J$K{Zi&~R{%G$)eB=`CaM`bep{B!h~4i!i3>Ps|vVbX|D95HIWA z8U1Hx(3D$2;u|OyDT={xOuj?tvxwdbaUxkUvwxvDoOCtp72;F<>A5w|$3X0`{=wWy zB_E(!3-F3QXx;&FN`H3VtT;3&pC(`w1;XvLE}#rvbV|St?E$$(js}9WB^aVknMZC% zk_TlpMghm~mmQ$NYt-qBw6+Fgw2&g9P_x&!-c>H&Meln6zW{iIfRRt60)|rt>S0d_ z(;LMpO9vxiqd-PBd59e&j~Z&i!ocQWG)6X*o%$p5tI5HR&L3D(L%cN7Z}g2kegDn@ z$$|yHEUG?x9Khn^KW&Isv>Ftk`yO*6V5hwLc zt6QBc?;nyMul`8Xd`8%=Qln^Yi2QqQAP_Z5%%*3FoT2|mbL%BDK^K_0I#tevjxPu_ zDIMo%H>FtpfNyHt4nhK81Odb8^~Hi41Fiz20Pf%kM_3rcNl`l19yN-5URs^8Eo}k0 zo9G)wk+wjXSF2I%3q{f^_Y@egtkl0dD{;Vx_iUy*&|ovcnUtB>bZj-Y6#_)h2J79O zo6BsYjg^By+%g@{H5BhGBObss!)^-<9Kj)~BG}p%p)Csvib8g;yp#rfNWan2e0KR| zfvx4;x%}xokUFfNj-EG>y+6k?<1w-2_w(>LA7F&OI5uBQ(eI1}C=!1eTaddD26wAs z7seuzbh{9`)9_nBX1yYaNf&?zR9NAw(Fy>>{{=lgE|!X1-Dj&kSK+K-AJH`mVw+X) z$1FxKe;^u^0lz003$&6m8cvQlQF*@u(+^8jj2G2I*(#MOtI&3^CLonr!bGDnxntv& zVsFp!2ZV+bVw3#V#sF(&mqdINo5L~BGOUIN^{cKOTzEP4&_3kRueo+;oEJ!0dmC+3 zeiDJVv{RVPS{VKQhpaXhWOJ5;zq=q-`rok2Lm)nkBVWQIUrq5JE?x%Ej4ktk_u3zK1QH@ix6av z`n!)y|II}+&a#0U%^ueVZzSJpAK_qRt_^vJ4UyVMw(YKVY$q{KCGXk&ZhkfIHv56V z`1hm5hC8J1y{+ba*u_;Ku{BHVJ{++m)`x`kBq;GdN#0A_oK0SX7w_pG-8Lb$yxL^M z7l=kVXL8 zj;e;%wh#dz)-37`JY#OTHAXq3yTViboqBt??L5rUWDkdgMV zpOTz!;0K%?*5$s7hjaYsXC}3&2+|S^g#1ythwvJbZ|vJI`m&T94A*0Dh=4C&-*917 zvh(h!h;IcSoMCX(G-E$}AzgZ7lNZlo6R zavNUU36KETN5BvYtkcN?q{2lx@aqcFh3??*pt>jAVL z1bB#m;qV7SF>kcM(zx=&3J)DU)6yx<9wq!!`ktR074!6!hXzwsVe_FcoElE5@-l#> z0G1)T4Gcu%V8J&b=`TJux!DUI+1uEnemr8ARg33pdNiDL8m8mYLFYFJS(egp3Y|6% z5ZkY>NY**Ixm~|LIX1o=)GGInb}Nly2gDlZlc-u|oJL`_s$8lc zT70O0?$|H*IIQf$TQ*J`ayusWMF_eoRkeh<;>f|4`z2u+#ch(Z@kq=Yips+naTCU3 zNgGb8sUR^iHD%;eGUel_rS#Paw34<+lrR;EVF_=Q7}N{#P(BGt0Jh|dj~A7cWY|V zWvDxZ~1GMB2k zJjitm)=PtC`L#aj*iXe0{rzKKk1!+g=fxDkgW-v5W33_9^FfE4NQC6_+m4SJy#g)7 zmaKZD8sg#T_aSwjN?v+AkVp4|hkt)X^&xtJl94wW3Cs8BfohFRX1iYZ`uTAbaOn+u z22hzg7nJ3@cr+PfRZ}&yjQR6K+kMD2DTm{Ad<7U|y&OmDB!->>n1Dy7&T*Qj*o=2u z0OD>ySdx^@TtStJB%}T-(J;#BSP+q(R+`UC2S=9wtgWv!%~vy3(&V3M+`lKMy>Y3a z>*n{54NUHTvsT10Qi85g&>mhNj%*4WP8vqFTJk5J4^C&vOt}|x4e7`OLIJPL*a?{J zlrk|m(DF$6JGJIe&ED-Di9n$F@oTyCx#cF0Yml>m1~kUZ_je% zL;^#Mk|;jQT7WOXNpHV5y0$sum41&5gsBqf%2%HHH%$7Oe&72;YE4fezNsD*m7k(d zA)xiS{_gv;ti4l>$2Mu?uZsl3s&rWxkwo3oyhSd?m?H$JhQzzK-{Xy`($669)rc-f zFce%LkomxM0u%t8K$kPp(h?2C3`Zyk^W2Mfd988pW`ZULWfJ}Kk1n$GZrCGHqp;PxKH#UqumoB8Bxo0sVWXRZbmjCk-uVDx0PO(F2vEho zEks3^%`s^sMk)4Dm7j|hhGUbY4vA|7E{S9Pw?;OSqd*%SHtd3yn%VJ9UAuZKM$4cx zQ@K=H4n*5SF_ps@#i>dTG0oMwR4I}!nhLol38wmhz{GV>~R+_Lew^D(4uFcUIPV9Xd=VB~QzS@2?;E+q5`u>FiJ0E|6YdV=;|o_!2&J zbD|nmfq6U->H($#oCjbTJM9`2SJll0?}fdHMcNDLN1t@6cnGhwicBS`YT#_5^+K}q zvl|PR){R7YARJWHuWjzVDw0U zO8{5`UL{4ma+n-T)Sz$sx<+ZLL}`j{90MV_h=$Fg0z`C=NIV@7b;*-oPjzMnsKhX* z=y$aTB0Nr@se^9>R7UZ0dNJ2@JfIwa^I+M?#o!0-d-`_jpdAtS$wmoZC-~_!9Sl*T zz+@Xm;z*n5lX$=>b`4(*D!4+F1t1VmWACOvQTnArcSEA7K#UiM5?c$zgg7cRlt$!H zZg@5yCJS^n;`5HHe=7R{ea`sUaJ*3!MCiVfuAOByIy(XGq8W`Mf2!xMnIiqxiK)!^EimOQ3k;P{&EsJt_=5QijCaPKBC>FG{!wY}QmBSO*HeX}p(x8<#g$@kG`%1(tx}xRdl6po37Yrd zqlqn*q|Bzo1C?SzE$r*E8+nnC-^dFGHl?hk&SaFBdJ>;jic!^kE~{iis;=~<>@$gT z`-tfWRGKr0Em{u)JO%J0;>{?aO`kt6;KK#$wi%sV&sYhO zJ8ZNa@%5eZLPVzj;ykU2VH{*jQnK*%4#&G3T{S^w_m|rB|@h z)wNq{Q+XzF%V1guY`VTZdbjDYx&EpmJI}ycp8<|d?HWbFXaolV%Fal$xr#lQ+HyQ% z8ujJ4Fl&PXi!H-(Dc!?uA`2il&{Si)+J=a0^0WN+AGKo8bW1M_NUKdo84r{J+&`Wpb(l)%PIB2s%x6_g;S0)o#85BdQf?%a<#e!!_7{o0Rl=3% z93rMBzOE8OyKWdE1`4sit8=7y$`Q}moBj?zqp_JedO2O?NBn`!=?vYWGVJ}p%E-cL zWJhTVl_T`Bbe#bytx@0$1vz|uiYd~AX}x@gXDh+|3XlH;_?m!GLg{ETT@5$4ht|7l zyH)CFFtUP}=ICiS+txVry zcVMPsfD(YOGVUMKiuQ0iwUfGcz}gqt6VDZj(Oqrh#oPAP*|zPdNZXMT^cY*{U|uS1 z1N*$|ypPrg1Ts&6MoD@k#btQ|O?DiU%}kWe5u*~vCW)D%uB&vicva-O(`AJFdG9%F zc`J)OnEJkJ5vkMSYGW|4N$u>{(P$)}-IjJX`xeIY|RTBdEN z@=%AjoTOTIfXGog)T%I%s&1rZ1wZn8G{M`6ho^|=N;1xZiSW5%fXM6Gd#>1;SDUS7 zt{79%JQqElM+_L|T2e<(in7cX`aUn3l$v^yt29@5mYeq{GdadNriG3&YcrgYs}mqb z6j)Y^3sJya*P}NhT(SyE(<)HmQq_6nEl@7NpSvoiiQkv?%$$-MpA+$=*KQh4-`4bKZ(hk=HY^UywyX?GJSE*YmX4#Rxk4!lS+>s|Fts~% zl;x-+rH@-7B|uqMuZzU0yzZRc(ubuARokQ_l-bw>VavWz*eqF(LUY+t$(!th&vIU= zgGGP~5(RU_izQ<)a5Mp*gDZz!f0-jT+W%|Dl>_G~ZcPG)89g{wdFZPzO3EM+Wu^$Y zbf;xFQy1TC(B3`D7|+cJc(INlt*a`x>Eaol4Mu2HS(Y?8%W-DjTB6w(c<@Y?1WbcD2qE6R5D(b6Zym@WBiD2?3*s?+$S%Lgns=`h29ZYsVsSmov9;ag@f&zb7VYV#`4C zY99@!#N9q|vLV{cZ6ooVCZ@y>fr9~<(6+;Ne9(-?rak2CoPXE+&czRjA1r=s+CTWC zgKtZ2~>&(Ncu9Cvt9r&+(}0 z7B%etSVnG^4+dGe58Cy6AFm$7#ShDJQK~BY*2+VK*538?<>FZWkn9!Jgzxr{22!+$ z%1w!nuM+2svnmrn;G|nWHVQp~`q0eaQT9Vw+pk+Lt;Lv#%8AbRBn#PDkF#3v7vs`^BJY zt!(8*q;USZyl^V9gCDJYx;-&-siHvtF(;P1k{*Vhhj;7V9z9vYlygVc`ibsmcZZ68ctQzH44>V`oI@^lpK@zt6vO?n>)9v@tU4Z z7c?9Y!Lzypcnqy<*Ofc)4gyl4UYYy}JP4mWfWJlfh`f1Aj4oiX>!cE%%)Do)k*!>~ zmVB1RzK3A8pzEoCD6=O%TO%6s7mZppy6gUp;<`L>W5U%T4vvG!WHd{1EPINM`GL)C zQXZjR6(M&}De0>YF-}ZKxVDNJs+3fy)P@@tmA|?PUpC|M0U|@C>S!=bpT>rL0mBia zGIM}xIb1|~4v3&Ge+BS70VB^B3DK!A5^8S^dwBYn(f1O8R9R=W5}Wc~3eiTnhyHaW zw8eu`5k9g$Mu`0rUv3rehLU6PgfD=D^8pv8gDib#ASH03&&pL5jYs7({1K0_ZScY&xn>U$9s0+?a&t1xjG;+d$`X~F`lcemYJa}UVk}ZIwL|)2B+mz z)(wPEv*CYfke5sa#d-o%9%S$4W!arVOkJGi@rhNtM6Gx}5#J?7S6e4(j@G=?p8E-L_eFHO1$Ou%HZF%>#0m+S;4hCAfxV72bK9@7^WiMBfT<$i?Ja zVEsJP4~As;fyIfXr}u$=62Q_}kG^pbcsl@o0&v294#Bt4M2wIIz&ijrZu157kaC;% z6QZt~clvT~TaYUbsmrZD(#RXShVBzP9iq8w|Gi>PNhsTOn$)JpzHZ2Z% z+m+#bXsrWS1Hj+zv{1_^VlEkQ*$y#%TxIdgFTfc;;NP9fXov+ zpe113RFA5y=eVXN0WWiQO^#dL`zJAIx#zjy*OK&n(?^FYA7y35|KC+_YCSiUvAd=` zm8Sk?M>X$iRa%4z6>{fuIIgD)KE#$NryAZwj-oeP1q z2H<8JnO@FG3``1FobNe*hSx6u@PAFbiPl>HTLInysDUcR0^t8da=*I14)2aa+Gawg zuk?bxnEJ$!zd^eP-S+_;0N}3%A42QNIN(bF{GHPkm<-B*jVOncx~X^w<`zktJ5d_ zwPcELo+zt2lSh9~^{%>A+gr5nlGl3IoN>?#`sBH=mgDT$&}7ezaUhO;wo-psuB=6#JHIGr{IO87@#Ogn2C<}>`zi%Dy+5B>%kZ!#i7 z7?BkXBkZt&7zAt^M~uT9;b@y1F%6rEnYK+MmSLXo!&YJ)wh`N~o!EyR#6ic*BhFzL zanZJAq=Hn4yl5S%9Ihf&!*1dhm#+G{Tr&}#>=Kl?zHmg4qce$EL{Neg$yku! zVzO{0s!R*Pq!OQjt%U&FR8)diTI4FgMgvJ$s z4FW$pC*yHAr7#Utmpej+4I4H?8}`gTAka9>zR8Ri2hSS(%terMlxxu_ay>?YIbRto z&h|57mLhPmA(}+9Xz4Ks42)Zg-Ngn-H2$+9wkgJE8&%oUs$phEHJ^+qut}d$wZ{n( zm~u_!l2TMr%^-!q<+E!QfCzZNA=_2+OppN2*BsbsRJmAW5?H6i6RL?sr=}HPuSre< zu~a?~hytEKKs6~*1(@vtk_8Y+0K_H|m>`khE2J}$56Uznw{+RE60C<>CK|^4h;o*<+{d>@O6M4096d|B!oY!>- z`7q1AttG&U@ST%t>LhbQ+Z_FWE`#~7mCDUlxw6D#%{A@7yQV0Jb|=At(vu4p;oR_ zUF$(!$WKEx&j1;(ITxJiS2Ep8hP(We_-$RVrkm1d7P(uto3_lQWn;>k;}777WxgyOL{A>G(%p*K#7lLPRzi7%R(+Q=8J2Th)Q3M!2+wgp?d`;Ces ziW6H2?W3}1_Ja- z6C^767F23AC=FC*p+HbkNOU3zrU$^C1(ky08VCUW=qXW5pQB<*DiH%#sQl#ysDArB z<{!D!UvZr2z^@%fljkcNXX^Wsfr_4m0&fyOypvS?63}?G_=$!dR$|ACg6C()aV9MX zk@%8V-Y1uBte;uQ2!tV7ZfzR5R}0=05X98KuvwC zEX0#aBB|^FPeSQy6iX6RC{5Ez%e4@$%TT>s0wH} z0ss}DQ;7(}crD~ZP|Y)6+89sGjkfD;nU-{0*1davTRj81#tr$J!Gi)w&cZ{RTS ztc*q0uRkakcFYc|mlVySuUmmmM&!@1k^z3L@M|1*>J!el=+fe0hQMP^0tFMt04rMU zlHxasHa{oYMMn>)n^W}+V`u0Ns!9OiQ?%(P`-VMQM5pF;c$nkt&=A8&=1xYk_|0SP ztr+`QU7o|RM}ZMtE=H^XSnuP(b{ogwe5FOK(Dy0vfD7+!N!hxtWF1FSPmA9SzIl~S zX?=&mQMhsDF*JrH1*qHy=d}4PV>?QQ;Pd=~-!>*}?ccfwfNOpnxC5MiKHwPWHssjz z-bbx2xo zyc6JS9s_@E0p11hb(`SJ$*N}qjb=3(9cNFCXsvbcy6kPPhcoWl$_f33iVgH$qZ31m zpVij_ZQD3^v>(GSP?)D_jMWd`KGnLDBt8SZF$iyj_@wZX7Qeg%8a5Os;X)im<1)EJ z^`e5K9{~)~&~y<6EWarfPfE(1Z5=l`0-MmhgLY`GVXzF!mKz1jsalT4XA(haR-bTo zHW8U~Xp={iGZWzGb8F~o5hTf43s$I1siQ$XJy_E`0sD&(f(lk;{?pJdsAg@SqncZY z8jf7RYClwKc+uC4_=${BJtap8L*$7kLH|Scc~x^PJ~aiw0T-1f^+<(d{@hagzB zX_Qrf8mdEUOOzVqFm4-7imw8Y&62h|y)ezbTfBWID3;w!g)W5>!qPfCe8nJKWH_(iH2f&f$@@Fhsw$C}+>m#p? zWGi?7d1$%y?b!TCj(hfVn`f~-XKS55y2@EzKl$3p?~Zr6+y-lggb7T#8-g?brAPK3$bQlD?FwO7Sav%Y7qLwd+&BH2B(JYX5gn zV3T6Xbj_d4al7xi>r>{{eO)PT)m@i&H|N~VdH0^oM7GMey(915o#{_m?%Cbxrxp)o&gSiHIeXh`b>oeTKfah| zS1Y|Y?APs!XP1t=bMp4deEZ=${U5P^=lX>!|J>Q^*xBqh|0k8_er;rGTHvJ4s%s+) zBa7Z8!%}~?uJf*A-)B!WFN~)8)BUTCifcm)L+NviN3Oq+VKZHsP=;ilUf!P?$~g}H z-;NG045s)0@Wewqv!msHHREY`*vy#h8vAxFiSLZx9)J5HW#B#gwncHp-jwB`3>SkYlw--zF!t5Ps+=L^cZ=(Ye()F>rISIhcLi5vImAeh#{b8mq$ zjaL_8AXNg6w;7Ck8D~PD2`-O$4BVsAf}u5}Q4Dmv=t8VRi8eHx;AS~1Fs^fn73gRI zuI=kEdP{WaX8;He94N!k`@k>bc@LUC$+F>Gm2fUFMdKzxSX&$vEzrH3osx{!KTH&`Aer@AT`nk{lu`UxE9M%tkMrG*f zcTktaCDv_nSj*FMgA?%Iau28p4(k_%`%%UbE8dS92Jc51h_RjeiPnGNUerrY_;Zz? zQ^fk}Be7ycT2hL$!vB+5WBRlW*tWQ#^}ykV@_Q|p^d4*5fUM9ghZ9_NbSz4W*ie*Q zq)$i5Fnn&sB~<_-mL5)k+l8^vw9v`LlXcEE)Rkdm?m*Av?Pm8u>gl{V3a zcT}|doe<{j0Qa;Yp^t=j72sU}FFXc(QvtpL;F~wWqyJV?^xG=AVf~(xLHI0=dtm(8 zt>b>1*iu-p64u+fVLi7#hU|nkJ$Io^&s1pB{bF14!Ws}Dl@`C(@6kCSphhfr+il{m z!am@UMBFX5(lloI+rA+@1y}z+H0uF)%W2vcUG4~pzz>ANZBaCnD#6YzYK3J@Pt9TAYIc) zJ@mr10{SbE7IP5r7?{yb=`3{Z;byT9hN02I2sEVynj36DylRY|eRH+tHZhl(H%(yS zf#VpwPO(oLC)wMEf7hptV$>0w!BFQmq?tSRk>7)%O_?BTFTRFdO_0NCN5OmEasW71 zM{g`1f^#44D8fsK3X=kB;8bU8d*3^>6ueb;v+mZ8jQEos?;ZNx0}cj%zo~_t^Az5e zWMQ3G8{#c+P#78&X<-ohRC5()kopW4!N7p0`r69S8sqp^~T9VgsYYSNY z0aP0)M}C04KZI%)N7f0^_g>E?LiGHOTtiSARiZCPLQu`Q1`5JC0D|_6kko>jf1CfY zAkd@3nSG`?^B_DV3N6G5Q&bMaPSrjTkwXxi;nSdM)(GS?tzklC4b#*FY#Fn5T5pjT zvGo#GL8w#~(u$Dc-bqsMwdTV9Kf-E1R;Xu41gdB|3!}(Df@%#C$^mSnxlkL*i#f)l zWD0?sNECrz#_AQE;n;Yo2x3)jkV`MT@evt2%rd;yM#3uhN@P}6jdF5EOBu%y3q2QF zp+e9amE+nI8kxblTnOHyRLdmLDgc@IZzCXzCy8T)+2Zvr&|uXLH@Zke3%p>(f}sfc zJ_4E`(hXeIer8qM3_La0}~OT zMnvUj^_JyoAUFxnk8(B4*&HbszO;b3@f)r@`D>Va5&q;DXsUVUp{*Et?fnb!PTl)7 z@Hz^?V_Whw>dNFn6+UUY)-mxR+*m2j``zdF3Dd_|sb z$ngz}`xhrx_@+F+C&%wus``X~^1hvM)jV`D-iF22TkSX7|D(tFt`EA{~ZQc28JQuk7SruPnuiG*i#Lg;CRr$5-lc4}y~wxQUB)CEZ5;!~NXn?0G}l6NV*Bri4GIg%R9ISzkX^F*d*iT!DP zuBL72-13pzFXU>Tx>NHJ`vLUye{}AbM}7f4qp9Pc0-E84;q;XZf7jtF-Aeb;C3u2^ zZS>*@%~)JJaleysH-6cL5=5DJ&3fO@GOmV)=h@;dYF|G3S0nF^ynhN482zOQ3?E08 z1VM|TnziR6@;rRNz-9QEbKrmQ7}v6&7s-dvr4RoMhn;k|V0brGeOe)Hm<%b9 ziG=^n01sR-nOZJ_W=6H4X4R6Ew8$!%r^gKm_5$2&2-uS0{Bt=OaK4? literal 0 HcmV?d00001 diff --git a/app/main.py b/app/main.py index 37b4256..72a1b8f 100644 --- a/app/main.py +++ b/app/main.py @@ -31,6 +31,7 @@ from structlog import contextvars as structlog_contextvars from .database import create_tables, get_db, get_database_url from .models import User, Case, Client, Phone, Transaction, Document, Payment, ImportLog from .auth import authenticate_user, get_current_user_from_session +from .reporting import build_phone_book_pdf, build_payments_detailed_pdf from .logging_config import setup_logging from .schemas import ( ClientOut, @@ -2271,7 +2272,7 @@ async def phone_book_report( request: Request, client_ids: List[int] | None = Query(None), q: str | None = Query(None, description="Filter by name/company"), - format: str | None = Query(None, description="csv for CSV output"), + format: str | None = Query(None, description="csv or pdf for export"), db: Session = Depends(get_db), ): user = get_current_user_from_session(request.session) @@ -2313,6 +2314,14 @@ async def phone_book_report( headers={"Content-Disposition": "attachment; filename=phone_book.csv"}, ) + if format == "pdf": + pdf_bytes = build_phone_book_pdf(clients) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=phone_book.pdf"}, + ) + logger.info("phone_book_render", count=len(clients)) return templates.TemplateResponse( "report_phone_book.html", @@ -2320,6 +2329,97 @@ async def phone_book_report( ) +# ------------------------------ +# Reports: Payments - Detailed +# ------------------------------ + +@app.get("/reports/payments-detailed") +async def payments_detailed_report( + request: Request, + from_date: str | None = Query(None, description="YYYY-MM-DD"), + to_date: str | None = Query(None, description="YYYY-MM-DD"), + file_no: str | None = Query(None, description="Case file number"), + format: str | None = Query(None, description="pdf for PDF output"), + db: Session = Depends(get_db), +): + user = get_current_user_from_session(request.session) + if not user: + return RedirectResponse(url="/login", status_code=302) + + query = ( + db.query(Payment) + .join(Case, Payment.case_id == Case.id) + .join(Client, Case.client_id == Client.id) + ) + + filters = [] + if from_date: + try: + dt = datetime.strptime(from_date, "%Y-%m-%d") + filters.append(Payment.payment_date >= dt) + except ValueError: + pass + if to_date: + try: + dt = datetime.strptime(to_date, "%Y-%m-%d") + filters.append(Payment.payment_date <= dt) + except ValueError: + pass + if file_no: + filters.append(Case.file_no.ilike(f"%{file_no}%")) + + if filters: + query = query.filter(and_(*filters)) + + # For grouping by deposit date, order by date then id + payments = ( + query.options(joinedload(Payment.case).joinedload(Case.client)) + .order_by(Payment.payment_date.asc().nulls_last(), Payment.id.asc()) + .all() + ) + + if format == "pdf": + pdf_bytes = build_payments_detailed_pdf(payments) + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=payments_detailed.pdf"}, + ) + + # Build preview groups for template: [{date, total, items}] + groups: list[dict[str, Any]] = [] + from collections import defaultdict + grouped: dict[str, list[Payment]] = defaultdict(list) + for p in payments: + key = p.payment_date.date().isoformat() if p.payment_date else "(No Date)" + grouped[key].append(p) + overall_total = sum((p.amount or 0.0) for p in payments) + for key in sorted(grouped.keys()): + items = grouped[key] + total_amt = sum((p.amount or 0.0) for p in items) + groups.append({"date": key, "total": total_amt, "items": items}) + + logger.info( + "payments_detailed_render", + from_date=from_date, + to_date=to_date, + file_no=file_no, + count=len(payments), + ) + + return templates.TemplateResponse( + "payments_detailed.html", + { + "request": request, + "user": user, + "groups": groups, + "overall_total": overall_total, + "from_date": from_date, + "to_date": to_date, + "file_no": file_no, + }, + ) + # ------------------------------ # JSON API: list/filter endpoints # ------------------------------ diff --git a/app/reporting.py b/app/reporting.py new file mode 100644 index 0000000..9945473 --- /dev/null +++ b/app/reporting.py @@ -0,0 +1,166 @@ +""" +Reporting utilities for generating PDF documents. + +Provides PDF builders used by report endpoints (phone book, payments detailed). +Uses fpdf2 to generate simple tabular PDFs with automatic pagination. +""" + +from __future__ import annotations + +from datetime import date +from io import BytesIO +from typing import Iterable, List, Dict, Any, Tuple + +from fpdf import FPDF +import structlog + +# Local imports are type-only to avoid circular import costs at import time +from .models import Client, Payment + + +logger = structlog.get_logger(__name__) + + +class SimplePDF(FPDF): + """Small helper subclass to set defaults and provide header/footer hooks.""" + + def __init__(self, title: str): + super().__init__(orientation="P", unit="mm", format="Letter") + self.title = title + self.set_auto_page_break(auto=True, margin=15) + self.set_margins(left=12, top=12, right=12) + + def header(self): # type: ignore[override] + self.set_font("helvetica", "B", 12) + self.cell(0, 8, self.title, ln=1, align="L") + self.ln(2) + + def footer(self): # type: ignore[override] + self.set_y(-12) + self.set_font("helvetica", size=8) + self.set_text_color(120) + self.cell(0, 8, f"Page {self.page_no()}", align="R") + + +def _output_pdf_bytes(pdf: FPDF) -> bytes: + """Return the PDF content as bytes. + + fpdf2's output(dest='S') returns a str; encode to latin-1 per fpdf guidance. + """ + content_str = pdf.output(dest="S") # type: ignore[no-untyped-call] + if isinstance(content_str, bytes): + return content_str + return content_str.encode("latin-1") + + +def build_phone_book_pdf(clients: List[Client]) -> bytes: + """Build a Phone Book PDF from a list of `Client` records with phones.""" + logger.info("pdf_phone_book_start", count=len(clients)) + + pdf = SimplePDF(title="Phone Book") + pdf.add_page() + + # Table header + pdf.set_font("helvetica", "B", 10) + headers = ["Name", "Company", "Phone Type", "Phone Number"] + widths = [55, 55, 35, 45] + for h, w in zip(headers, widths): + pdf.cell(w, 8, h, border=1) + pdf.ln(8) + + pdf.set_font("helvetica", size=10) + for client in clients: + rows: List[Tuple[str, str, str, str]] = [] + name = f"{client.last_name or ''}, {client.first_name or ''}".strip(", ") + company = client.company or "" + if getattr(client, "phones", None): + for p in client.phones: # type: ignore[attr-defined] + rows.append((name, company, p.phone_type or "", p.phone_number or "")) + else: + rows.append((name, company, "", "")) + + for c0, c1, c2, c3 in rows: + pdf.cell(widths[0], 7, c0[:35], border=1) + pdf.cell(widths[1], 7, c1[:35], border=1) + pdf.cell(widths[2], 7, c2[:18], border=1) + pdf.cell(widths[3], 7, c3[:24], border=1) + pdf.ln(7) + + logger.info("pdf_phone_book_done", pages=pdf.page_no()) + return _output_pdf_bytes(pdf) + + +def build_payments_detailed_pdf(payments: List[Payment]) -> bytes: + """Build a Payments - Detailed PDF grouped by deposit (payment) date. + + Groups by date portion of `payment_date`. Includes per-day totals and overall total. + """ + logger.info("pdf_payments_detailed_start", count=len(payments)) + + # Group payments by date + grouped: Dict[date, List[Payment]] = {} + for p in payments: + d = p.payment_date.date() if p.payment_date else None + if d is None: + # Place undated at epoch-ish bucket None-equivalent: skip grouping + continue + grouped.setdefault(d, []).append(p) + + dates_sorted = sorted(grouped.keys()) + overall_total = sum((p.amount or 0.0) for p in payments) + + pdf = SimplePDF(title="Payments - Detailed") + pdf.add_page() + + pdf.set_font("helvetica", size=10) + pdf.cell(0, 6, f"Total Amount: ${overall_total:,.2f}", ln=1) + pdf.ln(1) + + for d in dates_sorted: + day_items = grouped[d] + day_total = sum((p.amount or 0.0) for p in day_items) + + # Section header per date + pdf.set_font("helvetica", "B", 11) + pdf.cell(0, 7, f"Deposit Date: {d.isoformat()} — Total: ${day_total:,.2f}", ln=1) + + # Table header + pdf.set_font("helvetica", "B", 10) + headers = ["File #", "Client", "Type", "Description", "Amount"] + widths = [28, 50, 18, 80, 18] + for h, w in zip(headers, widths): + pdf.cell(w, 7, h, border=1) + pdf.ln(7) + + pdf.set_font("helvetica", size=10) + for p in day_items: + file_no = p.case.file_no if p.case else "" + client = "" + if p.case and p.case.client: + client = f"{p.case.client.last_name or ''}, {p.case.client.first_name or ''}".strip(", ") + ptype = p.payment_type or "" + desc = (p.description or "").replace("\n", " ") + amt = f"${(p.amount or 0.0):,.2f}" + + # Row cells + pdf.cell(widths[0], 6, file_no[:14], border=1) + pdf.cell(widths[1], 6, client[:28], border=1) + pdf.cell(widths[2], 6, ptype[:8], border=1) + + # Description as MultiCell: compute remaining width before amount + x_before = pdf.get_x() + y_before = pdf.get_y() + pdf.multi_cell(widths[3], 6, desc[:300], border=1) + # Move to amount cell position (right side) aligning with the top of description row + x_after = x_before + widths[3] + widths[0] + widths[1] + widths[2] + # Reset cursor to top of the description cell's first line row to draw amount + pdf.set_xy(x_after, y_before) + pdf.cell(widths[4], 6, amt, border=1, align="R") + pdf.ln(0) # continue after multicell handled line advance + + pdf.ln(3) + + logger.info("pdf_payments_detailed_done", pages=pdf.page_no()) + return _output_pdf_bytes(pdf) + + diff --git a/app/templates/payments_detailed.html b/app/templates/payments_detailed.html new file mode 100644 index 0000000..2342f73 --- /dev/null +++ b/app/templates/payments_detailed.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}Payments - Detailed · Delphi Database{% endblock %} + +{% block content %} +
+
+

Payments - Detailed

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ +{% if groups and groups|length > 0 %} + {% for group in groups %} +
+
+
Deposit Date: {{ group.date }}
+
Daily total: ${{ '%.2f'|format(group.total) }}
+
+
+
+ + + + + + + + + + + + {% for p in group.items %} + + + + + + + + {% endfor %} + +
File #ClientTypeDescriptionAmount
{{ p.case.file_no if p.case else '' }}{% set client = p.case.client if p.case else None %}{% if client %}{{ client.last_name }}, {{ client.first_name }}{% else %}{% endif %}{{ p.payment_type or '' }}{{ p.description or '' }}${{ '%.2f'|format(p.amount or 0) }}
+
+
+
+ {% endfor %} +
Overall total: ${{ '%.2f'|format(overall_total or 0) }}
+{% else %} +
No payments for selected filters.
+{% endif %} + +{% endblock %} + + diff --git a/app/templates/report_phone_book.html b/app/templates/report_phone_book.html index d35a990..758095f 100644 --- a/app/templates/report_phone_book.html +++ b/app/templates/report_phone_book.html @@ -13,6 +13,9 @@ Download CSV + + Download PDF + diff --git a/requirements.txt b/requirements.txt index 04f969e..c6ca725 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ jinja2==3.1.2 aiofiles==23.2.1 structlog==24.1.0 itsdangerous==2.2.0 +fpdf2>=2.7,<3