From 0707876a81d38beec7ee154b502a5849c298c941 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 30 Jan 2026 14:31:04 +0700 Subject: [PATCH 01/34] FEAT[BE] :update movement number format in GenerateMovementNumber method to include hyphen --- .../repositories/laying_transfer.repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index b3d7e7bc..68867265 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -49,8 +49,8 @@ func (r *TransferLayingRepositoryImpl) GenerateMovementNumber(ctx context.Contex if err != nil { return "", err } - // Format: TL00001, TL00002, dst - movementNumber := fmt.Sprintf("TL%05d", seq) + + movementNumber := fmt.Sprintf("TL-%05d", seq) return movementNumber, nil } From 1a9936eaa133cdc0ba5f9050392f996a2c051c6d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 30 Jan 2026 19:45:11 +0700 Subject: [PATCH 02/34] FIX[BE] :remove obsolete db_lti_erp-202601271102-stg.sql file --- db_lti_erp-202601271102-stg.sql | Bin 323287 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 db_lti_erp-202601271102-stg.sql diff --git a/db_lti_erp-202601271102-stg.sql b/db_lti_erp-202601271102-stg.sql deleted file mode 100644 index 2a8495d814b6d4cd13ac8c613d9af3150290542a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 323287 zcmd?S37llfQ6F3y9Z0JKbl=iL+ST+(qfynXdzO{9HPgF0(oE0pOpjJ8AxbsfHQlvS z-BnvvJ+re2X(eQYkYr9HFdyav8-wBV0S0rJ)0jipoCa}-(-?`9KS%JNC*waa#DB!k zJM}M>o8Y&d%e~Qj*c(hImB+)MwdRRhv-0WqTl+*STDxn!Ke#*{Tt8NM%wyrh?|3@= zBYqwO|5hvV-zUQF8=I%jpFXpBXC?KAX#XYf^~H^?XnQmq9L#%zgYEIo@L)H(EjoH> z>yFORTW^ByRw~p#@Ui+r^zKT3G#iL=*ctiron)7AXcxCmU);I?eYz+{n)p+6YYmG) zw`TMH!A^g=)7u^&Tp3UIp=-U_d_(6{o;m{wnO;j)P;@pt&K>35m_ON?me@0SG2Wp?(VZ# z;CEYk3-HBgczIiWjKu~Ay-OEQ9L}$x z5DAGtec}AwRJ$}H&{g<(3jEifOoSdB&IVIf)KEd>4+0anKvVFexD!{Kl_$V&lf%oS z;kNko;;Fkf&aF#Go&#SgNaV+nEC_vm7JMc8?0ms0gV3+ZcsAdi4rXjmC)h#pBU#vJ zi=y>vHEvX%3SWRU4R*)V8z4$Mg9;Yfy6gIE^VIL zI&*$ga{Ipn4F#Y6LPJPVh%RhwUAnM&G1}hiPy5^R!8E$opWXoTBVatczIo@R^*ezJ zqsi{I=g;hLws^DcyQjk)*nS@Hn%qJ+_-S-O|Rd$^UP-YyOXy? zSPqJT>Cj{7%?1w~1;~+&vMbTii|1c`G=hC3%uP6xZ&qyB7$CP$8K-oHE=NGl`jn17+x)xiy05-^)_1+S=*7GAz6j1++o z%+-zEapC+qF)@!1z+LDM59X<_@4ai|!bSug5(vU%b>(R4`$%AgK8F}5OlMnn_AYFk zJ#*R=JA3~8-R2K$7I5!Rr~Mnf(coZrzPEPe>Iow=v*S_iSOk1HedglU88AqZ=wK~H z0EqzVE|DYzG?ggDIV6KW7uL^Q+=w>MZ=E@{5rIh|)GV4^9Zn{L9YA6qrmf+8fIJ@` zM7N-~qWdp6Duq*0mj9kQzX?ORw2}S>><%Dt>rJVVDY5C#=Lh}$0h+<{N?N4eqzY@+ zLFwu6^5HzqSjjp34h0YxlkR4UlH?+`dbpn~iGugm+S`b-fK~+MhqImO7=g(#4$B@e z3@MTY2jWSXgc+4KIRx9@Qe!}8xdrSS^#V&@v;-#Ud1p z+jyyvyv*PasGu@JXc)EGBef$@4rbn?Xwbn;P0BYZ$s2FpICTkpV+r5dQ6<@HH`k7x zxaACJjb#sr!?6@WBrZ@@WyIXzmAG^f7DZ^mY1!L&Ggy#|%Ck#$SM|gIW3U|XUFsgGDp&R837KDBW-dhF5BQ~iU3@jSx0OARBs~U<{X4q!t4?>wp@ycOZP=t=Ad7aS6+rUA) zckLKqUl`S;8Gw!C5e6)0oKl#ecxsmAMb({kB(wQ=`>G6!iuqVZk z1Z6QGv=c6OIP32YvdKa46*pG03vN#b5XTgOoB42m06QT2lj!{df$8eO z`1;y0+1AgXJQkmjk1!KCWc-e_7&jWg%2mnhl zA}Zp#7dP&?1iKO{EdFjNuB1qR!3vV1BJ}Fw*7^moX}IqpS56R|xv+6=V{Z``rzYQR39rCBd^B|QP0N9 zzuRmRA;@|!FbFEvFxZ{HcXQ)3Kp=JW5`49Ob_;fSOaZw`a)M(~qzSMHoESoMB{ABC zOp+XY-5LCo7{ThaVYA>#Fc#QQnvP-l?HQv1)Fh3Ou8?7b6k&*fipUE1TF$n?TC;l0 zF(@G<(H@pvj{Jg{(v;Sh0gPh|*e@kn_GQk@ekRnADr zg1TGA90(<;X=pj-0C zRpeGjDDvh0fxjgAB1Vx#;j$FDKN^BnAXMJ<560KwWCK-DPwf5e?eQV3ujH`@x}=$W z)9RXg@|V>LuQ66=MFcZ^jj-Ha*iHCNEi<0=Z?%OL5lt^-MUpiiNh^|U*T<}|6={%G zIlzl2&0mSZZsHE zVrwj9PaJjL8O*k)!--e{;7+Dgy9q?G6H$-_HQ#BfunBPL=kO?m>wxD{eb>Rl=1Z~V z(88L3j%73~98tP$`@H)943v{op^yW;0Jc>l`R%FmXD^-Gj9|^Te)@ED=f>v71=tTd z4d33oe&0nrCIjax;fTf7eM&^k)QkIAKG_wmIs`WmTVW-ZIMF8~6?NCwehY_ry-}5z z!*1^oj@Ys~w!`U|TQNh`Fn$(^f1JqTV7d+QON{c(_uydX-WU#oUGDEhunHf*sWL~x zCsgsl!T4}@kEx-Z=iIZg)8ZqglXS2UcjHt1S&3@i$BIe4D}seyH+T(wH%K$HhUciP zV6tX|gR+f5NH+=l%NxM(r;=Jsd7MF1*TXewmbo7`*u$#5HlITdHa&8}RYet%~N zOqPuMIc8(Bhg%8$3IrmCPRYT3e>fsa62Z*kYmv+SQ6G+Rgg03Vo5)Y*f`yK48nN*8 z*vj9+uFLOhDRvhv^>#zVHcj)qT@1uF-P$X#hiq)qZQI9hAv+j*uE_V3b`WEmZWluK zkB@D-RD8RqO-*U`F0Q+anKyA0X$=JB7>soXbRDjqXm_iJ`vlj9^BcW+e|EJ8XT68J z2e_Z>*~5!KnX-qu31-+!(Y~>Ro{5@_#y19u^^Q|O3!oE$*KQ=Nn^d{!%D6s=m5NQ?XQ}^%5^db=Yn!AVs`6!gxBdkjg#qQrB6zTUFyJ z+d!7F3=_MhjhGDHY_SY&bSt)i9-tIL{@V3 z{L!f9$P&HOt2#@)3oO6t)e2sPbw@39##8Sfhmv{JTh`8)*Oyi6#dT2_)mOq;vZq%$ zl9kM*_pm&PyA@nZ&^+OF=MzvThwdomtWsxW6OZC4F`+BR-R4p1szmR zRXze#@~DcaoS`YMAaFBSwnimNz$JfRP%64GoWX@wxcU!wa`QRNGq@)^z$?yh*;PeF zMC8qbDwm2E&CG&4swz?AFD;gO+~JT6_~{EtI-snF&o86E);Nrktw9S(x1060}AW z=N)MiL==Q$559>Y%FTukxqA)i?cTBshwvBZtpJVX@`{c^&5oS~xK3`lx zec@-RQ2d=d7aQVagx3vT?SCCQLqrZd3Z(|xJnAblEM9h{K{@guVyf7TA0W%XWGae6$})qUTqG+e#E}gXeQy%&(?- zUeSIktZ3zW%x-@kwXQOAJ)uei9(Nm^Sk6-q!tGi(>w19MzNkyauj3TVt(c)`167`z zrXa8-R-;!&(2JfoX~`uh^Rezc2u&&&=U-G#tLg-9Fo!)_HwdRa+}z47EGIL^xd^UA zFCuDlCCbE z{^Pn5a(VgFe;hT>_4Nvt=mNW^VPa0OnzgpbE2=vK{^e-+P#=%jd((F!sc7oRH&r*M zfzcB9Xf+0ypV^OtQv>$p7OKL}Qa*2uXIo2T)24dDn}qj5e=llR=6J9!pJ541ezPL?`euid2_$l#TC?H$^*S!^AN%zW^GK1gm(AeP^yEp8xsV=l^Ze$knQz~wh`se~$BrEnz8>x*vgUm?QOo1Jo^`~V z2L>883Cx4g>#!MLp+oly4H;wGbkj?jYs5%}AF4Gcn)kz`xyIKbT;u{K`V~5?d7G!!R}9ivGPak z4aQBK(xOqXi$@2pY)>IWmwau*o6GuuJQbiyRmzyqu&i0yI>zz(V7R+Ccdme>m$_J7 z-EdE*?MfHhA5S5>7aUg)5-Yp{UiEY$tnOz5gM6!-!q}G!%hZMMj{J#|3@_bDqaKSz zP^t^c`z4ZaeIC$Z>@P)O1^Q*)I&h6J)xM9V&>8WML?n>V3|G`K+)x!3+0nKCKw4sa zO@!)3Xf=Hf>d@wxqU71+RH4#*amYmfjK*&*fn?a_CJz}jSfI^ z*rB=lHYm$zc47d&YU7vX&CNAew;zwA7C(+|x8b!hOu8xdsyyYsiB)dgUt37K9iRV^ zQbVb|xkEa-Tj{wOA8aJ%X58jm&FuU*o^5KqCfeo|`5N??o1c?aSD?u^Yp{GxM!ZT^ z<(3-=`B74p@nJnF>Ke1aEd4qt%;|Ds2!t}@x8-Sb%MF2i96Ba{9Br=0Rk0$|M~&P> zRA0g9vo61oMiU-45fM!<(1~OgTIE(fAunqQWPR>_%9@`yW4k};Z}YK;K&Vx-#h>pi z_ZRjN@Y07RlkoiOwXO)Y>ejWU;5LWWWts0W5NdTQufV>rp;o79AG`w2f>_|grAV~H z?w-((kroC+txgX@{sJFrb%=OYMt@(Por&EQ{fn9Dr(1)&Z}@EEsE@B;_QdWFJj4Uf zZXQCGEYFR2BC`p`RL`(NZY^$2AUlKC%tEJe9Df8#a}Nke#mB8G;)2Pe%xYymN8k=y zy-QHE-gNN5Av{h14;AQ<~UD4;;R{4dD@At zOvn4td~Y~|$FESaqF3YlWzijH?l>P^x+vain8Kr>sn@R;%grb3T*tL=KS2V8i9Ira z($8E30Q=A)fO&M&z^SjJ`B-E4Nr&(wB!tJaAxvqOFK=RneqIP*etApLOhaeB!gtj0 ziS20T5Fc2Nq+9kHxre?oNqZ4L(= zci^YK`YE8JL@qeDsp=)UIg;A{Ki!ukxQr zRSozmsxv1HH2&Q2WzeZpdFKv~o|reb@b#={1kZ9lpuHBz_#k{|@LOR16<&+18=1$r zlK#uZt@EeeSiTG620s5Yi616#U|g8=c=6#cAHc6#96<4sSjyY$kKkb34(atFzPPXu z=H`#j^K+Y=pMQ!JT55hycc`5CIZZQjepbax zoS)sy#Lwk~uL}MRX4Y_A1~2~bZTTkVtPKql<|y#S1}@>8xtWVs$dovVSOr@U58Gn6 z#hjuwvzCofBp>)S%HBw zFN9_+g>)B|w`}b$Y1w|7v}{BuQTHTd(k=qDxt1K7Se7j-anGjhZY}>e=&bxfe=rel zkGmalFIexHc-HF-XT8+1^DMKK`;OE^At1~RU$BpN-H=H^qyya=m8-xW6C`;u>& zws64lcNFLS;~3qxoWL|Ic{>s~vTQo9YOlb6ut%2Nwtf7H`b2Suv>ZP}S`IP$yWn9- zW9ZVCk2c^d#$-5Dd|ROll;`Qh?rP;2H}BND@g&{ZC z@CsZE8y_4DcI~|+JFM5<7Ohp`Mv*$De+(`tosQO$!*ENeOGoBGl$6&O=!tJ}O0d{# zydpL$c=j~VA>M|604g%RU^cxKSoJRShm1>S_r^!PsGWzY4?%YKoPL(H^En|8SpFfj zMO*&t!FqE+ph|hOL)TpgA7zEIU9CI=#%H^w)zm!zgpb%!A!@&7?zw=Q1N0nWqJOOa z=o_{4VWFI}Nuf!il-Wc$l;#`?W3V(Ue2oE)!Ov3R;a9LC(dt(4)UnT4-aNer%4eD< zJ)G6~nz+B5VU?Ut_|O(GM#2cmgZ^FD82ub+jKa7EY|yyHMr9=z%Y_qEw3GOw|_eTG$ap?r0+o2$FeE({v|=%z6wtBJVX%uAZg?qu8N z4cJ3D4494ut-zhaUAeH~(v2@=u*6v%NDnIvmoRkriTwF_(qQ3-K7Ij1bthEFFjRUd zyn!k)n%0e-asGuGuNP5iv>L({GO2TPG7Qf^{WBmzxk7$TFaK#ub0rFNdvHN zG$e19av=YZ=dTP+n&x@48wgFhwO3$u+0dlhwvXRpUSi!{m;D#g=wfKn?LufbgAYx* zR6LVw3Y6yx)?HV)%T3Vjs=RYD>E9q91pHkl4yns$EHqUy`DB2X(uTNuk|SkOt*Sjf zv8Ai>7V%d{rP5CNQ+)q0@0+W#IA@sju!O6LZ;io)lKn|^eF$$H;GfY$_$+JM2i0}< z2lKu04v~(@zk71Ie{j_rM>&q2;S}CVfE0EF1<$QM81D}c;HXb1+lF;}oepa=m-}$M zQ@^mI>(pQTx?hsx{T#2^iiw)em!##~H$Nqq?~|-nLNXjQOS*NTKTI~9fZOa=yaQ#rut>Tor}7G*GqESL-KB0EI884F9Ih_vHF~}A3*^*? zcc7degl4`|P@GJnkYZm zt(P?q?)4$JF*lvi&a!6?XWP@^M3{d2L=N@P<)?t*78dtrz+bGmQ?XYUSzr308vHEf zIKPpX3wcDy?FH{Ye+>E}u)ei%8os@E{l1IQ`o-wX zX}FejW(yJmN|Up3&!vscQybCd`ne5#2ZHHc*)~dW39%N2ck#PPb;v=5fV*Dl8(1#1 znzA)i8*goj$5~mOTx?w@XJo{`t;*ahSR5VbLyvh@@Ui@p)+E0qh5Ej2>hBt*^8k; zY9J9qQyUCA3n?+To#OOJ9X?Qk3^g-mH+udeR(IRuodL0Ksa3uGfJa+wJrwydeq4G2 z@K9XIOor2s{7LCs+==1QGTiI(<$%|{z#bw?rfkK56x!?_BHIZc+M;USN-&r5T>pk;h-3H7$qmnc z`h^UR*qRx3KZ{j{5X&q7@Q4q&W=}8?gy2iq5S!jC?*WO4*@;H{HP4u|A-4Tk$`E^G zJ{gAC>x3`!iy7jEdjkKXnTFWwODJlQZHPT`tRc1~%QM6c_i*uRX}=ooAJiJ+xeTR@ z5E(W(py8PDygwXy2H;1-jvqxLpCw4uOc~)V)pk6A6c`MBMv3)m+qE1D)15C-a=j+4cLFFa6h4%r+Ob#!rcXuj?gP*jssFW@mqW#*4`E|6b^lT9cWDs@{7d zBwN|&71&hvu`E(5&uAEbnk$ zz2*kx|5A!8{Obgi8l9RDCB+)gR=y!@D;Knn{*zXh;Q~E%e!?#~yLw32PJ4bNboNY* zQio4(0K4h?v$|mR#kJXBG{Vb&L`m&^KA86L>lioOI~5IX zB5uNKOlqLQdAoeLLuDDmeNZMEnyi+++|H3{L?0|V%LMpX$spx5)XJ4oC9B2TPG}#GW(ccAyc^8VHu>yU6wZ@UqccKSAqD392 zDt){ES4p)Gz!~tF?tw9}J`*u0$_ONjmAAsIx*@rzGQ!m9v}EQ%Elk^RG5zWgVp?=K z?plUTaH{9D4Z2pA-(sSdyqVBR;!;64XVI!Hfm!G=-{-aztM*icC8E{&{iWlz+n7iphb zrtxMFYu-uIjP*4Ys=?1vHt~79SdtTKAky_P^d+A$)I?Tb1=$2{TJANP$`JdY7BGJp z;ff(tgBZH&vtJ|4-*cfP)02?h;}f_s#Ig)tiR-qGdyePzoJe(AtxA*tAZ>ymc?IO` z--o>Z?y;7i1%e^V$I;2~-fV+ZBq*?Tx_ynRAOs6vcj%3;kU5y7h1wtV4|<0aih}rR zXMCO9g4K*p50>*d)!6*UCXCtaWfak02k%|wG`f3mAq?k*zIes`vTz3*APBt8OOMiW z%2!t)13ydIz;#|=n-#n@Nn>w>!0<|-2A~)34d5W)vPI0@dVm+79C7%feY$jr@ z-eiy6D8+RP#xKmulko<0vagRjEAW5%#qo66NI&6}*p?xSr_W<<8rf%`|vXh5oO zo==IdN#wSy#XL*SGG%$3dU2b3@#^@Y>Ck;-{6(kO_GkaOsWGa+@HK z;kD^{%8v)32=5hU$K1>V5NV&xF&MHGzDnRbRo2QF_X;j@*qejerhRIyN=?W+VEYn$ z^SuIVw-!za`~qGM+m#3!(BdHHxkPt(pX~imEtduKsja{WwxKsUoH4&IKHdv?VR5G} z>fu^e{`@9s2P6106YPK~96Y0vKmV?y~=5jW}?EhxN3}cQkM$ z%`xuMdN6nEh#Y#L2pASk9!9g+3da%8g=Q>oSK}}~ffv+j<=LK~@@Dd#P&SX5)K1F^ zY$Lw@mu(w$2bMC3B9sTIKoC7Ql=oYtLFBOvSi$o^K8F?5LkgR|G95tI-hCz`MqRvR zEbJY;vnewz%m@3Pz58nJfLf|nmaAt9XZU!W518%yDMjuPC8a9|8Gr_mK7L1TYjF98 zWLe0ObZszVlcE!am_(bhWM-0e|1R(B@G{|@rq7XZ?yfeQ_7z&yVSK@?C!XUMwrI*C7Nh{0_V&G9OI$^|@tJLKr$j*m$}l-}9M_;AI~o)Oa^z+^D)4cFtDee9M66X!K4RxXh!!g99-zzZ~ab z$w%(>C0yQlOA7wQ=f=(b^ie?Y%Q2%m#fMD@{tW!`FZkEXZf@ zf+25XQ9a>}$$Ozo%%V0xXa#nKy^R&BQUq&gSFotDa?i00>ZvPPg78Xd9X1#nS%h zGsvDpCckE_haA24*58_KEM+$A$DZX-#wOX3RMq4cUS(x=ZU&+BcOYEMeN8FB@H~T; zm}Ui!z`DKQP0zznI@4iOz{@-9D=cS-XTLKG{Q<2C66ar~iT zW>^6eBsox`5ppksemW;oolaXkux0w}nV-YQ8xcyKWUw_d3=bJn#w{B9Gju z0xq!SEE6FMlMdV@-5^}5iS?Bq*q6n&5BK4=n=UlP+ZQ@c-#%VfK{hM|zF`0AAl(M; z8ei*=4yiiQ4Kv4@4b@uHlKm9P9V(Ghxvr>z;8!BE74_wn0qp-M^fa^y5JDNO@H^ho zl(bucCBHFu1WS<$-&t!)e2R> zn{3ofMF?Sv7V&+Elmgo$p+1MI)nnQtw<4S>Za?N4a^*W!-TjgyqGc*oS$`O+B&E%( zCO_sXCM7XdHTeZs*~n?Cruj5fjav02MBA&XToAnM7jQvVv&Z%{ex{IVRIT0T-HyOZ zA71qpI2{|cR~gFz=KDIcQ49Z-MEqGk3t9K zk8{8}uC~R%W{?kSzu(&*?!w$kg`KydI<>s3zZ{c&8ZAPiOtxiXznm3P-GEe7Rvmm)LovOM5Wikt)V8cFaMSXTr z-X5d8orA0scf}&e}yLUe@_fkn>y)m;)>8KHyDH zdf`EOGaysEv~_mfOG5uDPWT$NuAB}r#MkSO^qFFBxxLNogNP78vSL0(;+;~_j(jHIf-(et8--YEL>L*~_ObAImV`l+omZ`z=;Y&zudpbK#Y zCHLNrL$2^9k5hj-0EZ^y#+{sXa-4_>xkIja`i7$f6a7kFOFEV32@~yT32&U=9Wu@| zl@*xf*FZ1Gb1t6Rs*(7(7BI--Kp(suh%mY_&fg;qauC#jDZT_PaHg0BmoUOk!P3kx zWR!QD#{UW3l|RmEqzSnbZh~(lJ4R!F|M~L(OxjPx?qiJ%g9}A=LnhBp`e|x#HqSk6 zm;ca==bSnAM1P;#o=M{}Jiv-G8}1ZwJY!zn&NKLQqan((k2Du!*Jw?Tn$hgW?C#)l1ec#ZoI7O*TU#~<06-ixm(#Kb+ z2M^BW=d-=xWP)cFx5u-2ZwS{4(ko!9ln6Xi#TK-9g&j9FB=FbfIT9q$mxLW&S|RfF zw*(U?+z48xJCz8&V7tRx#K0xtENUw-knAPltiJeoFK92_gD`(U+RI#E2h1mbNjPhu zj5Q_9P7JB%EY2|E$2G3qmPpn?j{*{&Q+Md9L5I#AnIclm$STF;Gp>6b#z&4o zYv|zh19`HSCiW^l>SC@@Bk5^s&Vv;CJEe4>L|f&qH2)6_L;kq7sv$E-dE!LAZx-kE0!8g&)`>GS zdKJli|ETknl%F?-`#R?7G%BvCT`zDJ^%&WU$@cVMYDdH_WhJrXNx@)2dEmhhQkpv` zlAbIUx(zJL@z-?4Qkl-HMT?woBdpvmY@h##v=8|1n0hvz?#qe5)tkzviQ4DR!K3oh zCH=7dAUeMpDavXqzjy<9Jm=^|Aw`jrkI2XeJZ2xgI+_k((FXUUhlwxxv;HVryW`9q z=OY=SK6X?=Di?!KfL_Wz6}G*u)%{}twmNM8JBjVlOl*yz@Ui@s1+X;a1a|A&^Fxp>qg*_Ff5 z=thLc4G*F#)A4>J)g~d7NqLN5%EYAoyT^nC!wMc~SejRcgVD~)MD|ZeSdV4GnlP+B zu~*aSfV?LFF=zTO{#eC|e&9XAqk7WJ=z7v8@-96_?&lxABbSBBT0l6Yl{p_yNN zv_z8B^TKZCHj{>&z-kL~R79ik+)9nY2;M(}5HT76t>V^VN*Oe&`v`#dXC!zS{tVkk zJL~%B{XYxHnx&T(wf+dA<2yH^ZMauyz{-`Oo00+Lh^sZsO696zRJ9q^z_Ov ztO9v4gQQllqGI!620P-rNyVpgaw+xaq|jnIfna|^rvmxBm_d@+izl(lC~?)~UPJgh zEp;L zz0wlk9WVI~Aj)rZ>aJ()b?%R^4KjoK&XyxOGSsTgDK;PHD3ng+CznjPF$u>)?e>8l zjLd5y60tuD@A2k6ukms%bg4@?4!3=Alj4F#e&My=-}53Di)7hOC-_O?T|g<9;_3u$ zD^Omu;}VXy=^prq7M>R9qyc|HP79cOoPiU|n?9$nqTK^u<%y~3L1jz?C2pWL-7Eor z4qcT$j^oj2$lObMgVM8Hzruye4kwe*5DtZBM8}vNOaE9#e>Ch<2g#b=>(|3=B8R3= zaDHP#ZK?VNGct3gt=C?E4oB;!;BCVD5dCe)TohgFPjA3d2UAI@@YUYC0f|9|Cv@r*Bfgz80G-D=_A`Zl*mk zZ4^q|C~3r`cmF>~Gmhz{+~J2DdOp3BOT{`z0e$*I@bhpx;1AQ{ya>7>($@itg zKA+%ary+b<+dgkm0w?3W>MJlS?8$hqaUa5>wxv!l5dNC9E%>3=htLY)PN+Z|7>|r+ zWo(J^OikTgBYZ2qY9M}4>f%&iIc9g5Fq!um%P6xZ& zqyB7m?ADtqr<@LOwIjD}G105BEGP&oV0SnhY}+_+9QFTrG~DtW^X*QKJxk zbQ>k(C-pf7V<_Y=cDF`?a&5xH5#hFnfdTtE1s;PGcFepFV{mxO%l|j2Zjs z)n2jvga{_$jDD{CzbmgVq?I6uKa#2i)Xzm@YEN=isNZ(7QxfhDuk!0wPN|KRi#{3yYl}v_eSN1c|2>v9=Jz@ zX-jby7K$gJl?U$H>7(ElkI5ukj!O8nuhl{ zvvkshC5bG8da@TZ@OL=fP|iZG9+YZ3>Q!ZftI8%A1108!R7S z8l(6y7pWBOU%+GD6zRGRV6^BTI0GfI_AO{ACShBovHdKkNf%qc zwQ}uCSH*?9v6ADu<14;`$FwEvwSJMDhBRcaZOtN5YiiMeGWOckTCt4Ql|6v>Tcz*q z#>~Es$FnWgF_Zoc+-#@e46S2K#Ug0xN;H_vI!0GoEtd6~l*xKc#eQq9Y(AC8QoSob z9b?+}p1Pm`>HNqbMaae~+m<9z2w$%UG}4TX&Czm{X4cEu}Mi%pCRrq)cbMRtKh*-a?K!P9g z@j|o~-~!brnjMUfSEN6s>Ox(`_3A3F2Y)+r<2UepsfoQOqVKc?3hh0SM3&?Pv)Ezw zo^th|WbD>>A*HjJM_X!MZMiY1t2`f?BHripn-=&`yw4-B1oDinX5xLBEtiXRNA25ml;7BK z_ies~$GTN{IjF7fu-F6ZsV$P^>q(UG|6@nrDPzMDT!a@KFvGRG~T}okJIuo5y#aS(;^&-%v~xPQ!D}H0mLH;Z$D?Z)}-H znz0udt(Gg_ZRLNol|RyU{gBH%#tpGXvfT|_3kw=6hWJzK?^k#Yhh@t)>gwysxs!K_KoV^e$f~6d}xdNFhYM*52R8Y z2;GPADHOxrdFPNu{gk6)%8|})yIi@4*CU00*HwOA*Y)#0i^p3%SuWlu5f39$K!Z-CYN0`$7SuqU*u9Hv?k{%6-S)t z56u8(O`g9mCG$i1Bu(X`G+p219LtY(wGx4%(mmI2MNwk^6^?o*r_eC9<@usteuCsn zkb2n3YjED_Sjz6W9M&Ur`&2T2lm=tD6S zngp#TOdOFWvD$ezZYrO$seH<&>r>v&^B^WtcGHe!XiqOaY(IvSA9ZG0VD|K~+AUeu z)f%*^)}&2$E&3WB=emfim`sr5To8(@*qVhTS|^sIg_wz}@NJcgC#2()bFK2*8*ZZE z&*1TFh112Kb$xcMmZ4&0SQc0 zErO)2MyoU9JKJh2o`)cNmcX*E^ttZp^VeYf9&*yjf2tz6RNU>tnNIw|OARHm+sPfn z)9YL7g19(!CH|?P>wN0`-GS6nYr~!6`aP@Eiv-6b^LUQ_6@(57o2Q+i-r!&dZ%V*> zaK|H?n-a=Q@EwAkUVnZpy5qw6bMPV&6oj->Pk;_dmn_js_^cvqKV(Vjjpjp*Gn5d_p;k#|zSUety_w4nbfPwk-GQcEj^!Ep4Ap^Od1eQnMrjhBA$i%Q` z_jp7G(wF>(& zH^BgHHl++Sh7KCGqSOb;eRwEqdpev*X`jfQMoZhU1Y) zee7$MdKV!NNVZIgc1DZW17E1b>t7Op)vGnwqBsU1?7*WEH*CtX9!><;;MKj}yg$3z zgN%#A-2-t+B?C}*GRjR?0xnuZtOxs((fGyy_Ww~UF_Rij+&C>+w9*EvG{sa4lq3J* z0}8osrICZ+A*Oo`jxcMM`cg;!!o(y-#-IsotnHG&8O8~T9q#K{U(M*Wfh!L?nS|xY zk7?}w1&tjfDtHcooftwEW^c~GOcDuWWnn07=yiGa_sH+)q5o@o=-sBcG~;p_B|mHA z-<2_PK|>DQC2!OS6B1gbl7Gzy`{JYFthYS|#T$U*3vS-_csl4oUeZI*IVC<|^WfPp zH~=-if_`9semGmbJ}O6{@`hPNi+Rh>zH4zuTUAkK23Uy3fb7D@E5_3taPaCJ4AzE~sr zAp+63+k~s4xDe6(&Vuq`7fLl=g`p;cuLrB8Na`bDu*4KA@sXH9ZeWnK;X?_#pGK%O z1y+guj-1q}d>w%bIHYj70>3abl+N*zF*Cm1LC32vCCz;7i5&=vT#zQ+^wLJJ(hMB= zo`0yc>}%=KLwZS+3Ej;snfPpG)Ji5KkHPMUX)D3wwB)bE0oNj~H>>ZNPz1 z!S4qlRsaVQ&As9bet*avxm#cHTYZe|P8}>Y2jg8q&>avaTf{9U)8V$Y(bjmBCLAgU zCg~(xW#OT78y@-mFH?AI(0IW8RV)A<&JsG; ziN-2&^wK0-Bf`sqE>q^JRXNem`3g1A8adDylAB|6Oyv5@VEEfyhKtOFiRc&-5k3j> z-25RI`+A7!>8Lcms{6%9zFQCXv&rGMAs0D@o#nCrjPbt38?Q(im>4iUsHEUp;VzN4 zEv3}2`%1;z+sMJffiht|bGlWZ?+jO0OA2$^XsKvUF%%N3o&Kc14F;ezA*ic0^G80c zaG6uMbZfA1d@c;u$qm+1nffxM;+Gtl7%CDq_e)d^*$giI>%o}DSO4!g) z#3n6q#HLK*qv_y*!vSnO?BGsKY0#_ud`EuppD5_Bk*O|<-K$r92x!;Krc z5=?Gq>mDR6a%o!KRa*VlZ%}ghPMQU9KJytV7N}lj38cI4?*Xec+kTEfQ$s!;322f$ z@KGmtP##ojcl(z#RPUgng4GP}bea)mL4;T0lS>&}QOe@Xt_byhm{3~y90G3;xW!+@ z{@<)W8q9jI*9VbEGi@g#yZKg!FhaIbg<_TIs*6`U^HYb|7r#M_7{a6S)qY^ryHNs-n~QyRbGg zai3BmO?7`T-y4I_;4utQY-b3Ur1ZI({oz4>WcE{1X55@O;^t4VZ=xUR6C7npA!OZ? zr*Pi>jfxfjlwbt}z+sOa9ktYcBP80(U>Ktq{|QnL0|alAvlQ2X;kEOu3Y67DfLw=+1X$; z!Xwst{o8*Kj(0sc8BRe$2h$~%I zlFhFs;q5@GwJ0H*X(S;2b7ef;2QkSIQa9#D18d1iX|;dj#xc&u+t=VfTjdgCi* zZrvaB4|<1_N~D_kN794HBN;BWzPuUcMtA(ZGHV1#G9?s8#S1 zUrN}rgzTF`Xe!|{dmMQD17%BZrHDVYrn&C0U6os}MgV8l@KTWF5*IqHJaprA0`;== zHCAc&j{L??D1Cj9g0LHdPa_~qrLW5n$fv;#ztmbjRar@)`3&GwV-@J7r*HjD`csIR z4Tjf1S*2-%nB29&$n1Zl{#4;?caLOtcv%WEDdTGKtk38w@02`UmXfW+rgv$KQn~w% zeDEWRQ4i6Kf;Wh8Q_rNt%iz)1yJD{RVa%t2GjVtfewB#pScKr%&f!3P?6?BC6<|8p zflaXaV7gx#Y$`?Hkw5-P1=}74Tc-<8v*0q(q^e5f=gUCwA2-=edp zD!j83rJ@5#6wYm8n8nXK-$U5C-OZf8^GLfnx3p9L+AZa??>iJH3xKCPbBC>{<_+qQ5#|iR%^J9sBIg9$4S=1JzPV;$vTjPN50@>h zqW7j^m4lxR;iBVJsEPBsy4@m0$T;hE>dtH3RLJdkgujRY8_*xX?3F~Zsg_x2MZT(Q>^zS9c4ON=HBaitx1?784C>xO8PsoVK8P^|a zr#DMRe$WHa$+4G$&q>oDtY+d`ko_A~9*ms5F{J*$&xP^VSFXb645x6XA9m$%;g{T7 zElmMcPQD|G3i|)4fAtD@r(lggm+|cc#ss2#@m9Xy@7r zm=!j}W_W<#%+Rh56q;l*X`M^gNMsFrDYQb9`&o>yEb9?GIRQ5LWZ;^sX(2)pA zM9tR-T*~j&Yl;b^+(%TVuq1)o03w5gzP)C=X!C)*z&6_(P9_-n-X7zfOSokT#8aP% zc_+;QdXKX_E-26DB_C5cmIsndZ+GOoHAruwL2Af2m)Gk&v(8^+p(8WfQLrEyTCMSSt;`{3C9u#y zT?hHp6w|A4+QZa~X2<^)`#Pyha=@SRxT3t#Kha#-qPbG7iyfLYSF$Yaw+GpwRx=x!vMdeYoV!>RJoKwb?%L!7Cy+p2s0Ik=~IciPFXq0??0Os+%dJbeTT z4_l4;2Y3n61}gHXg)UQ`qZRA)5l{bh1#FW9Hg49%T`kNamtjcVyILagoG=XPpiCmr zO-~=zMwB>Ey&G`q<7%LL!9*>|b=reLJ z{5=KE-yk^R2iE$Ps>ladtvnBY_Y%Ot7%&U2NS}6b3P(Z%X8A69@GUFOP=Xi4R40 zpMqTsC2^B>6I0;HpT8wXirc&(qpg#`a5<&B4=87Ko|zeyCc^S#|Kx2U|}%V6sE<|u)#U~e-7VGHVK+NfL&tN??$n>GG1c#2&7B%K6|JGfnx zIlaQ7a2Dzn9(#)LD#k41Emqf$kYj!kF=mm?D?DU;#-Z?#lXO=wVs)3vj`*?Ui184i z!{rRw&Sk|Jk}DWz-L>M6BgctWT*n172AIo=yR>j*CC^V)S5F^LjtnPaod!xDipUsY zE@z&jxdI->u690w94Ycp_bOwAuZ5zS8tKxKBP-FBuX~>Fr;!5_EOhfyg-uRA7BVz( zp!IT{sjBNWK9L;h3z3DYSFq+VIizDTu|Y0=2|C(DZ{YKo!8=y8+&|u?m?^kNh=t1 zn(_(;Eps2(rP4FVK@+K`G8A+pkI`{htVzN!OE#!&d7ah{^O@u@ac zftBr*x+|sUkpn}X>Fh8fAxvONaXXaF^yNHa$TQf5EOU9U&c#}b`h0S1;-a$5QIo;5 z+X{_LJzH9~MXA?ZS$qLGEOGx>2FSvA%XMe#HFwI4$lU4Qu;AueX2I~1xjqy{6xNctS9MkOW#lL^ZlL?5Q)}c>({nl1RE(CU z45KdjJXYLt7nd(5M@{4`(E`V)XCdaQ;cAjF&T>75xZ_5Z=t#t+dXQ-F&^x<`x{pU%Zl ziXf_Nx${!oaV_*Qa*P;ZFo_Mq@|w@B(8&($jN2`B6&H71oBT?0VCWs18_xDpXa)=4 zkcWiNu}+&#IGjDiP16Bsf-cc4rMdft{$;fjLp@{6Xe)1x?)nK2cmwtb~{sq z%GbSm&Gma;LyifZdwqhyxxXG(!d%XMm@=oV~4-fHnD4VH|9x)hOtK@mI;l|5ia%_gjP7eHLcHruAbePW<^7Gjpe473W0C1(cK>~oQHIF;u{&RTb zxmIgI!YdfEJFzy&A>)Tm&-goVw=ck9poXekuE*PSXQ383I1Hqv2bW>0a=CysMHpns z``S&{&25u|#Py1~-5i$FTzkDPEfmd?SDW4?lnyyEJgA3vjKhPu0w% zE<{`FuRpsHo!>lrU&LJ(zA1vA_0y*#OND7Gy8FVJbL$uGi{7|#AKuS~YsHldMVg9E zUpRkvc01~}vV2iQTvUce;I0^6l#)Lv)xnF(?nKmP@b0uBtt7h8=Du=de&g}QI5CT_ zzN}2*s=pMpDLh}FhqRAlitf_*#VvO;?2j-zqWDS6D`X$5iZpU(nyoS3oUVwq>q)Mw>(bj8FhOW<@Z^f)WbpIj0SYI1`+5f&>7XVM~Rb3UH8hKOWJqYeTjiW|hGr43DIaLEo#pXX zPXbx({u@ra>+pUn&LZZ@dSrg%@eR!)raFnMxy;Vt8Ja~5X&=WF-KFu1J$d~LPF`!B zdIeS3xxva`jE{1czpk=DS^fD-j{k7_ zu`*Z@|MALqPv1K2ajwa~DFgB4gu&}*bZPSpqy&oA#5b7j&J063-(Ortr94K#9N9snaK#--tv6#j$-~k%ZGZSf(P3jbK$XICf=pc z2`KDKblfcW!Tk8-F<*h^&D$&7X&=TE-Q^{>=FY{xN#h5xmu5{w4UCtmFMM0S zQ-8wa8HyTsgehFTLM*Eoy8k+Xk*ks_he>pmNF%BsoJQ# zjO~Zy)es8dx|qBg(kzZ;>AM$G{WcoExZCJ-rM#*nH0E9&uZ9Nx{iRar-QRXu?~y2&MEAWweS(Ys|EYRGAvJLmNz?9cbfbzo{f#li9&2-9h1jK# zxtj2#*r&fVu5rWlw*NP`XVi$B;>|(m#SGyLpm;VsL~^NaC{pXZP6j~=s*;pF_M%b0 z!66ja>*8#I$PgCEJYnoqv>6)GsJ}~tgkP|d6*^lW`{XLE?c?&2RCCu6RmLcG+JMtD zVEE#)AIOr-V#XpBGDos5sf?B?B~f@$fYm}#)t4ztae1^_u08v6PO9p0TTH%sDXZVj z4(MNK^3`N0%=CAxgxwxMO;`4C(_fV1|HpOLVjVDY*2U;>1A6Qv&(fLKBIa|^PjW3g z4GfV4*2;wE;Jdq4P_~T zY-M?*+wPs2`*@^Vm8Nfz2&B#J5BYo{JVn?qYxwCVKhUZe)JrCxuXe(^MyAUbn&;{?M@~L)u3*MR$4e_5#FT z%ZYcR+7ydNlM2tzp9qibPOlE;_|m4IThV?8SNg>x>w);&*90Wb67m|MI1wn`v)`rr zWR!v`fSU2pV*T^jY3X}oVLP`x)4T5M@pU}Y>lO5-1N{s#ot@bi1h6QBv3+WW4+Yvo zV|8cwK-rSKFOOIMh~rhg-LAYc#VaM$vjmIuN4&|~%O%>HPM9IO6l(-Siy4cpCKFH} zU8F43U7z=hS=1Y`I3sR)n?O6@Q-xy3mQ)a3-j52M5x2y7G|Tdxy6%$@U&^D{sJuKW zyD1dyoqle=W;>72l0K5f1D1}7aIKjF%%^Dg@pe8JS+JBs$Qp9x_&*-Rk7pq6G~hjN zG!JHH$=q{<7s?Oholf%+Zyw$mK)|O%39w&^Fb}4Xcyao0G8qlwxo>&WG-NbPJu?)K z8GezHj0WtW~aRNfv?3|-lSPJdC31lDV=!TnttIXGSe zmmKgQm^j<1uQot>2YBqcKN}=I;E@c41zaj5qEt(ivla>qxDELTQ>`rRLf6$hyY@Q4 z@32U>Wk6i?RN))Y?##iGvzR2#%4R4auKMMAwh4+Qpyh^je}}V(kRnvfQp%sxr$~il z`iX_vlLA~3FYa-I2dP}9Z*6>r`@q7qHIs%Oa z7{azs8K+RBBhWm@BDU?Nc~$rJn16!h6}Zi@)sSSLV1kwAH1%Qcpuaz0!|b6$I25&< zteKfs%~}$>fy>CyB^|28JlwF&mB0%3{N7LUtf-6gdt|Sv4|zoio!_G<Six|zQj3S6&^<)yRgPOGnHFz$3( z9dRT;W9p=%H&{EL^O%Q@1Tc++u&3IkrqR%m0H#@%7gU=|^CWJ$8`W=Tc+#!a<+)X- z-@sEt<$-cJg^Hv)W;G}M zG0(NaU~%&?(HWLQ8D1wrDj#3U(yNxcExpHZqg#VnrQ_*U;wc12p%UNlJh_%zfD`1h zRVY!I}?Q+b)oA4f|9v&-A_aw1zR_?abi zj$O};0&~)1waQYO^t`>u|6b_vAF3AhxPQ0f`Om$(iaQIC(h@?a3L^EQn=EXV>+4 z?qcxkbYUCx1mI^L7)ZRd5m6rs4wFZ+LlFUr5(O|-j+NR_M1UgX!A=z_NrKz1eYl(9 zK(|`&3j1I?gKvK%uoq8(EMy0Qes6!cJ5}eOy-#?F4c$NY$WS8d z6(SIZx7cS8w#NW@XfMwr<)Q7B5REM_y54KgKgWn}w*#5P8XzkAR;u#^OvHT7GawZ9 zVu+D!nD-BM`qLe!KLj)6zFsKo#Wd@4Bhcp3JgIw|+~2_Qq!G&u9m1OR5)ae>H^$pn zlb`T>2yLf%WM~dhZBfQMr~__~3Dl`}mc|>B!aKR9p2OhXX+oCi7YH4|p5QY%3TW|S z4(on|%(p}s>+{eW>J+9S^KFSUU>eHHqu6whW*p;CY_(;mL-x+xp;6Zb@LUZ04KKx^ zPzOtnhn(mYYf(a>4z_8AAzZs9@W*urU(NBSU5zWZ8!2~tq~7&mc%)zbVRa#N*p>!M zk<8DlWFIJvxgW;HZRrKOkdtT0_DkTKYn^W4_|}5_Vi?f%#vqt7gmauxYR&SH3 zl-1eq!v`Q+?QnFXXI=^E$!|#8`gwsN(_~0gxi*bqwVlDkzM@0w0?=Cu_rQ}@cF0S! zrC#r-?7Z$FncwEvQ*Fx27&@<|&Y=v_{6ONh09?8s?9NE0%ma{kWEp;VQ< z@{NN^t3+0}VUg9Jl!dIOw*q8;ne%3v-Btx(wHFU%M&`Lp{DnQi zekmYDf&yt>^b4Arw80S)bKqx)BBt2omj8mXFPkpe<-x1D$I(B=ffvWI$kHdfCjRAJ z=*_IL@u+djkds*?8Mu(%3UK;M4ySrkW-Tyt!pu$&a$!I!ZYVZuNl~!bZ`sITC^l=W zdjQ(6N?N#iS38?2BUp=@-OA&E&vBv5 zbz5-L?q$$L^Fl~Z|JFwvGZ~L=?2o6Dz2Ua2s<_3JGIY8kywjr|6>&)<0M$ht#nm5+ zn$qH>K9V-CMPjN8^6f7TaJ^O)0K@;C70Tap;@POzWLja&xg5~tZ3}10@`=pKiFyeP zMK0%}ntU^fo!iRNNI~LdC5mp4~HdDH4f4R1`S2=xJZvJ zxPx&A-D(eU#JM#DozqyE&`E*wAUwg?KDfKnO=PkTDd#SWADnzhsdWlG{4yvchS$+wcA=4^gxr0cQk&Af#}xYn$S-~e__->>U!gI9_k z?ByG-Vq>5@Wd@mg2?PNmakoKJPXlP1LPdTR3!AQ;=-bjT#ooQUxAHK>K1|lpF9wsX zmm4NqB_~X_!U|yeY#ydsOzUq>T74EF_BIQ)h4Vad?)crY7&f}3q2BEBG zOPte%GVqjk&=bG6vADwZCk)|{xDIz*NIyRpq{E%1(L5Gj^X5=z4eoGfg}8nWk88K` zd>2;}{{eAvk$ra%*<{Tk*rxY=a0&_I`fBKfKV_$cBiWS|BKz$evQ1dIL<(8;*iX+S z{4W?ZxAwP(TN@zZt1yD>>T>*^W&B6#r6>t%X>Y+gZ|Hi!SXT*{~5;wtFBsn4k zn8~8FP$s}ZT=5i0SUQs=lS3#0T6c5gTRCVOZMn0QfEI!wCj99dPdtClp_#1b1L&-3 zY!hK`NU=Lfy99XL)yx0K!_zdgi|L9ML3}yd1&H*n$(orEiKB-7?Kx(x!ln=?e%c`0 zSOHZ3l7p%PTOfF1EhS0%(E>s-w}PUSsF(> zUhG1Yc60sd6m$0RD(BYD1Z!>0t#a(-$@$>H`7y+z>RWx#$<>k2Cy`=knq%j_I@7PmblFxg4XS^uh$P3f!HTk6l$LW%J1t7 zL+;aA0rXdS=sRM)AA%kXl(!mlr|I_=0iPmR6n=^ZGT?M!22hk%z=9Fag0^r&LM)h# zAw32p*OaGI34{E>BKUjMih{2Q0CF}(OQLhg2RzeM%=UP_S^*QrEECk}V2TO)RXJ;1 zUFpwd-F-3v*!7EdsjWrZ0Yo^zb@y8?0b2L+-h_v?%362bGv5fh5I>wpzF6$m5SQLJ$Q^l-%s5dKM!IWn~jkWH&kMjQs1M1?R z%eu4Fih{4YQ;v0Kd$j^4+`}=UR%=#X3I>F*?z6=1nF~8!tIKlQll6I^onTFRhY#;$ z=9(&#Z7mO<_p0Ls9=;Y2pPiHzgU{B_4WF%oHlYA~w&DulyU4>=7jF%a(syM#-p5`0 zgd^oIZ63@eh$N}pppvx_-`D0BPm;z8px)x4ZdBHwza-T2aVF4YaNF?(W<7#XZkUO> z2=Zs;2brL-0*Ej15X;NTG{n>X!R!iLqL7d7x)$LdS~*S~u**X$Kf>ugWb7OAy9z$r z(}c?aU2b}NmiQ|<@Y}GvfX|j7VCE@2jM@w#uhR{E`p3=_{kh8myjbeZY57PcI1s3i z4oK;l4i0{w-ne)wY)=AI%`DxYFOR$T+~}F~2grGG^|8d__^v6SNkA+~TO$E_kqp5dYUf$7pZQ zk}&*Jv}V0T@5*3=v^$<2d^N|A2IRMEK*zHfVuxT+a4itIn6VZ8vD@z~(Xv>C%5m31 z9HDwu25X$4`9J1Z(uSN>c$g@QC6mFy4n7wCz}!2z__a{IkYP&DT7&_K>Sk6>d=|Yn zTC2k58Tbx^>2MoavzW3ZXqLedMp?dw=S*81zs};!;jF(qp!k9p+6(c;P+Ej5rn2FQ z`GnxeZ3`)jp}0bRd>O}&Iy~O6ZGXmppCfP5}>v1w2Tr0K8OJ@>-Q(A4-s77w-47 zaJOP}V#dge_3-`k=$N#Kw;suw6qLb86@OZW>{$sgxjD4Hh=)n$F-{p>7%be67{hcz zY?-||3nyDO4^Xx=ir9&4O-^-g1yFr}hpItI6gXb$j;^b&SrTPy<^akTMx1gqtUnsSJsU`)49Cd)8RP94Qsq?70hm(*QNx^y8Ec4H&#AWp=sv_l zR~OIaxzLTpxjR?9FuPI6V9jK;JW$D!2oXj}vLTY?R{+fqanQ8kjiYBl-w+z}Fgaf0 zCS>U>1&muM0gL`B?PMt82nvQqX;5_4o+`aGB>pq$eGZaZr){nDLq#oH5YrjY|5AbCv-IOJZH;rAXAMBR=ojV(M9B-bR~V?mdB zj2j|Pv=BQ2Bj1p{KQFxGof-cjrPN6VA6c{bu*N=_DuSO+DWJ=6h*^xcd5pVe!k~IZ zCy+=4s=SyuPo)fD&VZbsWcY5aP_%b=*juz{!*u}q#wK|gBsC~gwK&c?QP|wrX+UY~B38**Yr#?g|fF(_HCHicM(A`=Kmznyg$L zJaVZUzA?vN^W?@<2FA?9^%Mr{P7`j(>lx5EedN_y7VDnK zunm9XOP4duO}4i@oDDaz$5R;?yREohc@2o5nEy!-5(g0YW2WyRr8qymVi1#ELgR1> zy21Fd`TMd0_@BnZ-!Km%g&kNK zNK3l`cfKDBsKlP&e>#Ia*pJu*e~Atz$L-E`WbikGMgjPfO`}lr!QYHF%YfJM8U7Ok zXSWmE!|JMAc%9BXQ?xUXZyT5ot`7!RU0{PQuN3TTqCV189dAL05}zt4q#rtoEa11v zbB}dtX4F+4BKW_f*w1x_jG4GyBC$&jzhija&aVM@{nBk#vb*;r=?K{PFpRP$wX}Po1u%aP2XnKXh@vS(FCXsg4(5;?YkVNz-|*dJ@{F?{ z2T?x_socv%AE)lEzT~ zt%>9X(yfPR@`o06X|B~3K>At+(oP3*sJ_A~zSMEZVtI84_wSXTV5bMyl#+L<^zdwG zSh^dQ3<0ZCI4!5PCIZXfsmgA7X=vk?`>@vQcxbEUVh`6d_&mLaC3YCdi#&Fc2=C3YS+f%%t?|egB?!X z<)b_s>gEVGEq$tII1j4UcvF!j=i=1lE1{ zF}SnL52p-_Y55VsFq1432>@#dhw+5cqTf4 z=mvsnDPq(BmWQ_E2Gy={(860|c9TBcGbK8N)P9b&6ba+Y)y@f|Z4U7Z?6`C7tN^y_ zENtDXd5}Us`>7;Sk0ZI&?TX~I0Dwt41))q(gEq?K$wYhDx{LsmxsuE5cy#qqBa4M zJ=RbVB3m&VB3l-PDGx-p#tI;MHxH3~iZQ8#;DOUOuQtOXq-te@qY5H2^t)qZZ&m=y zdw5vvoky!&iB!_A=)5TmO`>KtJc+^x5r5~A?AZ!nx`TnK(}e}IeynIZ*d9+s>WH1e zWYjZ%x=1D7i?trGDGfXQ$;t_^(nSV%D$g-XwuB5h@F&)7$#iqeW7l?9X6G2}y49w= zhhqCP*xfY?;DX2{tCF~3f%FQDa=20C_-iP1PfNhf4I^&yxY^qjPH)VgE^<9eJ5A}H zK+gP`6FXBLJZQu$kF=#5S^;Wj7}Ppl$OPtVYWfvG7qwv+wRB|?HCZH|%;mu^-DC;) zxoH*Nz~k36m-Q^?Re#I}E`r;+5H!_uV`yq9+2dV(Thi_83eY>npx3QdYbI1;dIVXj z;2n^wVxw_B-1nYxA(x?SZ3;Wz+WG6n89;bEl4MO+P@nlQ3fqSgAXV>sw%w;Vewu@} z(`cIjpE*UpI~`x2?=5<2E}KjOY^HEp=hF)YKETHiOB~8s8sd8Fz61R;JhUwnqUL&L zKBvw)e9)@Dw=G~QI=nKUJBJ(0=jrlkoYj2pM9%Gr20n1jkheEAN;|y@Ct?r zofd=(^*(_xVLkCK6DAFX6%*#M972ALgv;#OU~-=z7p0VxqopERPUD~G2AYr%+zh?s zNS2oMxaoRTM_HI57?TaN0l+_y^$siR)~)AZPEc9^=6MP|>3=N(cA^(60Q;33*zH=~ zqzR=Vx}hTCO2pw$FNR3o5YA046Li%BN50d*CwhnC&R>Vh-K`S1;KmR)GFX8T7l|sy)Bpbx)qs-x^|B51Z#7*b1QkOd9&Q(`wZ#w*YV$!en}=qb|NpKPZ5>rBV=RTM_Z?LX&j#KC|~L zfc0eztY9weeJiG8`jsiqV2}y5vxpP~RX5HU4gQ2)RzFq%^2-^JL3Zt75vE@WkI6L` z`5bL&E)ryl0-bh2r0*A6QKno)eIX^${t5to9t&{2$ruL+hTzK{$z4bn-yFt)pu)oI z1V_B)ix>x@-4bARL+Q`wVYN4~({d{w-w1(9UTDvtkrz^Uc_Z000?J=#&uDW6aK3(U0LDLk8jM=}JP!V?#qbRCvhF-bo#VUnO>|Ra(TMoTO zO>XJyW0eb`M^;XtXTC29JwvZt^y)2nF1hYz^Y~%L_;uT{Obl%@oKE^vxQ;U!k8bRb zrxUo3?#|2J<|1D;0iZ6F#!ogjzcCpckL2&t!8t*SoTdSpVVyoMt+kMkRbH8?xwG(Z zadHCf!yA>-@}!zvHEIi_kRZcsEs zxsO6?{%lQjbA_nI9BSQ8vl2wWK{RIZW*N_r6H9O z+9^#fq6jG(MzRvJQlViJima?OY{`yfMkw(==iGbFJ?nk$$?fy~{XdU~Pj%n#d)}|} zdcIz-_kO?8iE*4Skn0@Kh9l^;7@tGbCoU^7X7VTIS=giwNV&L23#R#TZzH~@p+Pnz zcIuI9rwo9mc{)&2pf@BfzkA^3FKn*)2L<_s2a`1%2<0W5G7tJ9PNkGAM6DvpZtnhG zF8m5!_&2|jr(Y#Xd8pt9Gx3$kEmTgb>-T|G%|kljROv#Xi#MQMT%Im&VRRa%bizeGfxTjJ zMT0xxBFp;^)VLq4MgwHs8&xQ1#Y0#)y!wY?qq9ZZsPN0|mB;@MtT+0lZcHNn2P(@1 zt4tqhWm4tgU(5B+@e==a_G*K^B@CH@Klt&IzER06c3UVC5H))M6l$Ogt*z(-*%V(g z{~&0EFS;O#-i0ZP$${Ubs1bfTvDOh51)CoIVshZ~#aEXuxp$)gR+9lpP44gl2#TMw zQ4{~0txyyH9Dden;y?dCP?I7+O*}m=7rB@P#|(l#4|5Wzc_8@9*ON|b;q=iBF-z>L za07+>AQnm_e1D`0_9x$8$1qgCqKj_}9rHbxVv@Z8x`sTLKe2$*HC@|+PR*2w1s2a> zp<%k>VZWxISYT0RTd0-PrfCOKrw-I?hsSl%Wpa$&YtXnZ?U(VI3l9$V_l54;B8EX0 zof-OZUE1=**P0H8Z;Uz|lJ;H!M}PG6B;+COg$F^K_i?=`auXZ3=$tShPW~QFXK+%h z)ul%3&g4?W*Az#fDawg+_kfy$&b#UXYbq2n0~fCVc<~nfc}nx3Ty&EM)NkUo1wWRk zE~E?*0}=2s4Ag-LYDwa244MEyddJ%2QSXOfjq&HI_|iZX2mA{_XP|K}y6-Y;O;C;q zz0r?*(UtZeYOVpKIj%nc?mBFym(e$a0QN@zZJhRm8Hk3*BCKi9+MV!$_*f#o2Dt)n z<&xvtN;q~Q|H1R596f0G8<{8N@$~ug25!*Fo1rRUA@f{9gS^Aw)!oqjk}lqUE=Yoe zH1sV8`^l3gncGXiPmoA~{t0|n@Hz0yDih2n+S;3|INF(+IGC&0PE_GP9qvQpl+2d^ z%Bf;*VrrpcZ#!MZe1^HHBlMMtjy9$alWlEOg2UbYeLY6wMf7nC^n?a=y#3S(HL*yc zgik{Xrs^K48Ib?M?<*nE1^VX}9PHvB=8HHeqCjsq|4=W!zjFkBFZjvTB@gVZ05jkq zeLna)KUxMz!oM=$Kb0K=e%}891Aak52FTxSz(7a9g8!4sARf$si|ph<@1UjSfF)8{ zq(Rmp+8?k7u=ELGeleL~tCJc0LRgrP3-+1#Y@k)KNWCcdNoA7`W}|}~_bynZN{a?f zqmtRmiVXUFmSC{#VsgRuO)zl5$`WEhKN6b?L;<0~h4u)-bfBN47I6ZYiY~Iu+1n!o z+o(i~hLC~EXAdhrgom(%MEs7Jq$WXPA`M6eQp5sWV*GJ|XkqJI} z{X-LDel!GdmoPECF7j=*4EUMFq<}487cCaf9gw2MCc%Mj8$<{Qeo||cRbUbZNR5K7 zxua{lqG?orU{|A{-)G4K%PuAnHZ=-XmXHbhk=Rtw8Uhb4v1C2(q>nZ?t4~y(NG!;?`{c!W$JF%v+G74S4I5c$2p| zr-FHFOTcSZNP1%fnx;<7`WKI8+d`p;D5eivx88^GEf=yl;!}h!#36XCK6$hv7EF=9 zPYJ5T-T3D~(To~liB!*I*HgwGz&7?`(uhflO;1@U9M0cR0mNq`Ty>5zB1P0RFdP2# zWPn?UA9P4pAT%9K14o!jrIXIetAtNri3j%uG5KKo+!R}Nae%Md$0$v#3RfvCLK23BP>h^2m4HHHp2ENX!pSmFdJQ@ zSAeeUr$d9LQOU6D6`PP$5vk3Hf_GBv1QXSlfS02PM8+SOL|b3c z<)c!~Zw*yqne3YTbYh6x)^~)27B#9!TP}qy;f=(nE11RRqCY9KSxI2J{IL~**i1xe zTJ=O%W9cQ+%)GKvydx(8hHagR*azi->>EUY?~56aUD(y0fWoQBDK99t2V=bv=wcAQ4Ul_^8^G+j$X*2k zPDcMl#HaDzeXXb5ikelZU9mlPa%*|w$H<6!5}O=HxXTSN3bPGNP8aFpV<*JZb|lg3 z8K_>#u8%K#I?FJN7AS6=$)=Al6b|QQr~u-#f$n`Fj9yZ1rPv5&qlYZu7ak)_2S=Dn zrNeH7O!x$rK@#@`F{2`z5i(q9LO{X~#U}(UD2T8Lq*6cD3XjGqgv2H1#bVw2|R3QnC6{G|3$lIclvwV?ly z`_72`Xq&2}EL66c|G<`QW0h36xa}aTR@1~cS4qOO0Y5#J{#eFGXR_AU^X0N3q7()j3#En zpHqpfXXQo6GugpFvWgk~*sRz@$`K-f{}fyMSRDr3%eD-xeJ-*y1KICH=M_i-RsZaE z$MB!Z5&*L8O-usVK2?T!B)(8M1aQG(GvEm45fBf6{w77II4}bpq{k-QGerkNm`bI= zuE!>P0?Vd9?h9gaVbf#7l_rEE{7`H{n3n+ekc}b{f)0h|pS7vaGmub)A`Y}WDkLZZ z+7YFK{pRB40k08qfp+S9`*}r5bcM1_v{Es(wVCE%Z!+1&fxnv!9$z+@;$UvC;$Sji z3baSeX39)-vlsR(85QV16Eid1w}A!0b%y!mNj550<}+2)F(HPl*qcu@w>P&jHJ^q) zA2E1!Xp=m=Qe3b{&D3O?sfigZhl-i`6mw_`+B9w%of6)aE=~E4!%HF8Tu}N?;Y}g>~stf$lz@oKxFV{LRwsK z0=WbtEA+$+=qQM`fMqU9Oe!ck8O5iBZV$xKB1OR8KuXZ{aWEzRra9#QWF3MWi-{8Q zAUaC4CH$XJ;=eW7ooHngpAwD^znq}CSon7ujFN}6oy05v+8d<~O`;%it6Syu7sks~Eno4n7b z3xJ-1j*hkjd@v|sTcir%FBkYP5kAM%Xe2z0jv^rgzBD35z8G}A2c?9HY&8nnkq|$4 z2_MMw2(nNZ5>oM!ip7v0FNJ#;`L!KoC#fuuA=I9Mk5z-!0HdR{>Og*%!K?_{W+UD6 ziJaQP+i*>MQxQVaL853tEI#}QX@EsWK(9Ffz=y|!w%-{*Z{g-SKRh%Hj*@mLj4q)U+w3Jr8VNy*n54|Q+Nn^6sb)oY1u z5?ttTCBEyT6EZsF?Z=DhO#<5451oW)485c$bRtuvi-(Vwho8SMbjcgsJC5*$j@b`$ z3x(byG}L!qAiq+DO$GZCI?5^7KPb}63uGFn85GF6OpwV?#UPppDjdKv&}7UY1l@XJ zfS%tL($ks+RrEG61ODa)E;O(Vn}d#)9{JpcC15kqK~`V0MuJ2P;Y+|)0y2RsZJ|Wb zCIE{-#E+OReNbS0Pq00Bx5cZ7um@Ob@ofP*27+$`==ws+sdfuMwx9(~?GJ!n6blAO z^&tGqCD;c#>=;BF9_0qLX@t)J;1p)y7eeHTlnd}>B#jIXS9x2o5{nKESthaA2>ep8 zTNXX_hH?*&7Cjq|fq}jba$OlzsUb`3$Sby?;4EzQfDpl3T|kHc4+imqhQ-35bFbYa ziKK)w(ACK723K+Y9C-OlD+Q({iAgIKL!=c(?&(0Qu}S^Z5PF7Cm8;D|&d4FfAjHea zp9yPZ5KP7{E3s0iZ$|sMfD0v2#aajSK~?A}Xdg&Wq2f@^U_eQhgN`9Nk5EBkGvx}d zCW5bO<@+gReOf4xDbxhhgkFs-9$Zb5KV#vc`&ywTfNL_8N}`H+g-BnePJ6(MS3sUg zmVg~jOqw>~33C+Aflx;N!hk%rbtFcz~W7TcIPiFa<>tNeVs$)R5?5E+pR+ zHg@5|yW5ndphU4jqNm2d4$240Ax{)91cBs0TVC|2Ac_4Oe&~hvkdl2Bg!CkPRgp>p z0j4RO2p0>k0K8;x17ddlXW3~Tm_~51fuo>xwDbk%$WX?h7J-0kLre5&JePz4iOaC) zjBVLn|Eww|%t(<$xUFv4{*%{|bwHjzgX=0xCv5-`?SkkDn!6N;kzj}h$bF(Dqb9!q zL0~KqSPHat?Gi>nP|A$;c5A1pzvkZG6scg{t@k03DW6CJ}L)h2`n37MY$ZMcy`` z${%qSMq|p|oe?0a+7Q*jBB>$>KyJIa)rw(YiIhYnhRqd3RuXKfvj+a5qXL7I4m6; zN$pxICB3WRL~H|e!cHeLt;5wJFEWV+qtlfbLE~if=v;imyaGV{jrGCb-;y- zunjm1AevLiAzReYwiOGraQ)Z!qpiKkW&ukhmQnDHKp))_DLAA>mmD+#>5T%~FsI8O zLPlq$UC;qLc&vi*A@&K#8kiPBQJw~f50rdZsYElt^3hiTXfinVGYuo7-xlhF#>*+0 zFiFK?3QzTL1t)KEp~X!wW14C8ATU$@R1ZZwdZq&6Af)>l9Dq^+Yovlgi9`h-0$drj zOw@TP>n50Hc_@<7xhEwPN?&Z8@L)1Jh|F(jbI5(9IUt-IU51Mcuv{Wnbk3fCQjsth9c9|QNMMH6 zqy7hkh0?SEVZmiV(0DT9!92>nG%g^*y3pEiE_=@vO6|e{G2Yr5clZa!E*v6dp-gmK z1*s^>Ijs?nov#YZcj1V|k`R5dxjilNaEB|Hr7m)^0L78$qcG}cP+bN>xve$YVvQ14 zFp(rKfes~_jIx~F8tK@vM2moQA{I(yDGa)7LW>`GxdvQvo7asVC07eNIRxn_qbQ-t ze}CV_f>Z3^X0M>ez<-Wauna&iuuvKPnIf3tv*L>S zi9J3a{uRW=&xQ7lBF$t0ejP1s^4$-!=p~;vp0f!TCOVKP@m=7a3wA8kUqGjVu<8$33TMpWVEsq8w&bBaiQ=bK+~5<9Va-e1)@Z9 zCnFFPZ7qFdr7roF7Pyfj_)HjMKn)WnpCUgPK!{Qn5sy#?Iy!B-QkN_+oo`X36+0wS zPeP#FYBL7JP>ZLJtVy6qE@bE(5)=S$loB3z3SxPU!OO@6FtenVNn}b*hn#^9FG22B z#ZIQx3gnkz;7pZNEZ%&d}X$VGuq@5 ziLA+~ZBCkLYL%iVnvq#F4@qxgaYjP25U-%Z`Amzfs0A)}&sP!+z z;QB1yrtNG5x{eTu4LlCHZ(mX2TCd#Oe?yKEQ&g4<=g?u8S7l`d4kG6;Z{3G%vm-36?y= z_n`6v52fQU31~@NZQ9BSz0DKX7D4MRK@v@c0BVf3rg9QIouv(dWg%J**-`$7l@qE- zhSrK`&5Ca$2Iw>fN3dpq(lN0B+6Z)53SH%dz8Qm!&_zy=m9(aE`Zp_~lZI#~bU6T_ z?e*xIIKomX+Mst{L=lN-Ka^zf zPz_B*22C_Y{M(zf)>qgFyk%2s=0? z?_^`uY+wnCBGD2i(0L$sATMeCE6(w3ScxbKnNSR|m?4PJ({g!S!5j#eJO|3hhAw(Q)?{NrMX)1@ zAr?QDJ{L^S585tbpd+CHy%c}@vher-xVj{N#(c`f4hGebp9J7Y&c-j0$dI3hzWIZs zk*hpmKgppL64D{07f)<_&`uD+$rjL_0@xq96!+7n#|Itc1}%Mq4~&B9U8I*!bC4Td zl??L2TTB|FF9L*;X7Y*zk|qb8M^fa+0JB-0#k3^|q%@i~FGYy9ZP%bPMwD&6Jptf( zdfEmOCVcRuZXV*fWZgm$7{9m#H@^Zhg<57)AQj5i3czwujDvR63Sc1Aysa`4zgE2~ zZD?SxaIxYAQe9)#f{y%CW`%q}W@7-|Q)MbN8+eUmXqa1|r(1|8Gz5oirNr3}T{4y* zWH=R~l}YzpW(%VG1||u`bYSBod;?Hj@S)*zVrD~Gwh-`duf_GIO z#|O3Mq4Or;{b}S^fE)9I`X9xI{>|U*2kuZatv5xVA$TTBhUmA@l8GS6sUmB9EMx=y zSJ(q!+Rug-jG8=sE0BO4{R;XfVzKA<r59*D>;fR}u?5 z(q@NfBMuI2Hs;sKdgN(NV-R+2=q4R}os0@ci(TYI=8aaUh^=2^g;LNHyNOVz84%8i z`C1dw+0^8i#u#EcnnySbBNlXiG)LzxP+zCaTlj&X8$j#Q;T00}?h_v9>m3vl09~wt zHv*!I3G?9ME+`z_V__Ab$BVEM(ARKM5zVHJYXhd$5hAtAU@$D+ODqdQn(|P0oo`8K ztwCwZ`_o$x)E3m@Nz7_13!+sN$e8~A9~cv@fJD2Z%>oWdNOekp+?ucyD~1l3Y9@j} zE;t{jeY-X>K&1WxFY+>>H$bRC3XM&VqZnXtP;j`vaBDtMY+NFANT{!epKl<@$R4m( zFz%tOLKQAQDg?bD2_zI@LTJT;P6z_KDYzD_&%eB!XtSYtGqEj%gVt@($_suAhjOg% z1fYdLm7HJ@8|Q5}DXFy^E)iFOfQ*Dq=auhKxaYRsNT5>@{s-6Bh!~{?Xi#JghI@0I zM#Z-abp9W;3+M`Q(gsRXuwCd#82@|Zz?!2Vvv~S1?$v6uz{H?L4`OQ<96yNpL{~q! z2r~a)Ua@r%1baX6Z3VIsq?UnZHqeGM$yU@sw!(wHBN(d|dH`Y2195O<;tAF#elQ!- zJjg^Iveh5MC^b$Vbe$QgbXvwv=;`xM*b9a>;=;Gg36IJpeuAA0gpLn`%z<%YoKOfTOb{|r$|V|t;G7fuXg8e| zkh6(40o+Um(;;m{CDEDy^XrHWp98H1ha&-e3=n@!1m$2aD-e8+J{MV%M0yK&44f!6 z)b@lU0*E=&>3hT{v12YMj)+?DA#)ud8Z0jIGc8dfip>J7K)g2N$u&SHuXV_$drblI zglY#4WD6wWfjosHI3Tijn1VaOMp{KySTH?ftioibEqshUQ!G$is9L~xr1hx$_gk!Ar#4;QR$|JGIJdA!2 zT#zi9uyDF#8f(GX02T-!K2x4xibGI`b9wsY<;1oirW}0({&G}IS?C=H#1JM7OdzPI zSnGxzR4jNCsbU`kyckOt%@tRNGpM4Y!o5F1BIm!nW1F^Tn|ldmFF=X!zok&gVPCx z6(5`6xIkzqU4T!AJcOnLz-9m)Mvk007vkj+6yoU{I1gH380_x_4}Tz@NBkUV?=AEJ zLCj=;NPG~#002*7RzaCWq-b{ti&+4+lza$eXki!AiE_m91-hU?Nz5mZQ!JXq1~A$M z(kIi@1=G}#(1IWkB?HpLvVnjyc_sE`oW-yMihYyF6#E#!5=+M%m9qF2p2q}*Q|#+F zdFh%v#C$H;pW=h%K_L=p%tDJN`9H~E4FF(yTF~~X5d^Sa^X8Ep6#j%o{g}*3NASZ$ z(j=8g#L2RzgnW_d%s)*1F31ZrdLc<1v4A0b(5VTiTZvYSq~T^B5E!&a5gv>n$q&_u z#80R_Xz_fJJvT~dB;una1H6)w4~?));|xR{Fnb|Fl~RiMaB*`80&gU*v@!(4)k1n7 zq}QOr#WH}PGTpI*eHk%*cFu@>hX@n<7yuDV2ZvUst_JpXwoZzDK`e}jPYQ!7daM=s z%w#Xqzbtw0Zq(Uu^k z_#Cml`&g@oJn*Xz2r7n&_R&!u{qdY(0L(2 z3&VWSU5W5AEI<;Aj>#nkV*orAU>ShiI2nkx!P1o(Hy7Y$*qBbt0fuw-Cz~ z&>jwA6VRe7q!G;JXputne4s5r`_GL2%@)u%1BHd_|G6cg5)kb`&}zajmjMR|@**K{ z9qrZn*J+JCpzk8KH9+wBMj+T2O0$w?Fu)oN^q})W#v|4MwY*%gGs*?0140)v^J4=b zQRMLO4Mr6Lo(hZ(fc%&gM5{o@hXJO5CS&}83wLoL!uMa^hu$bey^Ppq0pFU&4-&%b zX-Gjr4QLk7%3U$c0<|MhpkUgLg7g9CEV0#Qfl5I#3%HLVd_Mh_C87|bO+?s&_#apZ z`d(sL2;VZGo-|UQpc|QV$s?Ns02ac7H@X?n1rF?x38D_swg&VwG|VMfyx5mSe zve?=Mk{K20*9cs5l&HyHV<3`VY;o4USq7n2jS(!G7P>w2)cl{-aRRF z45T*7)AOv5LMxd*J9w45QcsO`kN>#SPk| z2cmzFwW2`d%EZPDzceCixO_lc08SEJ5#U+`Q`yTankZk`Hoz#@!^P1P^eQPsPRc-x z(7~6Z*kKe7>49J}t>z_RV&%j5A$lTGp)7Q}a|kEN0;_i%Ri>p&B*EEG6-p;QTfx8) z7adOIQ2K%WfNY@)@U+>nMXvG0R(^r_USeOHJ zQBv`l>!RTpUF%CZm9{^SIW*u1A4Nre3rgQE)CIbD6w@AvcyXH($CpavjZbM)+DwO1 zk@Z9~pG(R@(3|+Ip>^HFDwsoF*F6lxS|2(*ygvkpzspudu-R4^d|bT!ydptl_c4&= zKZ(|bp&Qfv@L?G#gUC@RK%2zoNg@TMhGbeP#b#M^UJlsx$0KbgZyMLNP>I zVs{{MO;1bcB|*N0$wJ|Ur4XbPpP10I1hY*XJtUm!7UF0!5s6Aed9*aTnR55|NuQu}wUUY0;Tb3*;6gmeTh18b%Mex2zj|T!f~A^t6zD-RS8X*hGD(3iTuW zX&8(^nMV7mkEJ6^%|$7k$Qb(q{a``w5EX&{6ZYi7iR^KybPkRt{~xnQ#XuD(7JC6|0djOtFok_* zdiES>wV^f=wNV=tx;v#;2Wk~DMQ!Y{OukJiiAeQ-7_~8RDHSf3()LlC+DanVxiUa{ zP_}_{Gz?A(YZ84`kX0j0wU345tXO zT#X-Qp}q5n&!J(KLq1T!3jm9!udO990MdA{TV#M&Aas)h`iqOZTcDpPR(lv&p%0>S zGBk})2oaT_csf>aQmbHDun?2NidIIkSRsg@b@T#ef`hBH$xEiZ!K`$V;TcpiAuC}t zVr=;^4bPwtWyD1)he%AANC&OeT}Gzi8B|y>G@&qvM#SRA=Ot{uBEH6@aBBi}Lf|D2 zy0DK4rum^E8LxRkA@Jf@>PO^I4hoZJnQWR;5G_?oE^^ubDPNaXy#ST8Na@>@!G z2()M-a$G813`dh&1$}O7UIOUmSkV;aiA9~?QxVK=>7XkUC_NP=dg>elUFbXb(2mVj z3jJL85!u~9p*@X-#)MC0@^oP?a@j=gsB4LKb?DdzHi27MFp#J)3t2X?i1I6IiXR)0 zMhtraiRx;j=LIA2NGK-e$ik0=bJM6lftY6e7fdlh(9(%i@yT@VjHM&~43Twfm5^-6 zVnv`5ip7@it%94caA%E?*vf<0>hO5{1~k9zfcYq;uMeWg*nk#1l))b<JW(BYK)ttVLF)ul^5ir*s0tuI1$^brv#1O@w>W$=^DyWzCD1T zp(7#T#Uhya=r8yo9T3}H;sJMPC=I z$%XVu5reiU9UCVZjww9+4?Ofi5ybDWc;su3^o1Y&uaH zG~Fkh8@u3y72i#v#ku_Oi-uh;c~D~{7$uKzMXX>kHc|O5=prCdJkPMLu-JrNa-l@1 zxKui_XmYFYGM&*x)QBi=MTGMN=HIyUqkK=IKB!x3Lfz{!Rcle^j_o z70M@Ax0y0L0}9P27?4vQPv^yyOkf#^761_;j>AF9rZWXfKEx)VK`o|6I7KQ7aY_!b z++xw^cgfJ5ujue4kG##IH-Nr@E_BV}5RATm5Ohc|Uv&6a^1L31@*$Cw;irM|(v?M| zDXib<7`A$d%a)bE0H}1*wgE$Og{wf}|bYM5?%cO=@R}gm{T?1sf z8#c$Kg8jzUbD5UAVGpB4Ex4b75+jIw+t_cJ7Aa%mQr#9Lt622V!DxQPi(aWfxktMh zxVi({Xw?UDHE3`yR0aMYTV@#y{s}*a4j0|?|5H+?B7~$sqLZ)<`Jl51lof96LEgoM zHmbs#qp-G4sEY7cLeC!*NSUs;5I%|ymoSx35Rq704lyuoiV_M#fhJ5M$SD>#Br2jC zN5Px^I{>-q86Yhhp=@|=!hpoM;*F_ABYdz3bfmI~^n{6Y^j=aMHBg2ELnzwOMB7rJFv@gas!%5)IFbt^g2biLkwlY4H#%`B z?O2%~6H2sLR6*j0RcLqs4X*;Wq+x<~65G2&a(9LPElN);fBE8tq=e*U3HXeA35j+R zu5e;bXE87-W_<>qPjA7+Q2t9BX0QRDNJ7f%C%P~S9!e;UN6*_t4TjN zS<^SgG{Uw_F@DJ~*{}Dj!{0U?^y~fPMPo_z>&J=B2b-lvyDo@a82%;r`{FM*zDxFR z8rbi5%+hxgMii{Nqt>|QXVC3;yPoC_EXrNla(;2tWeKT{y?UKIxln(+ZLpT^TZst| z7JPJZaydUTsAzn2Q(0Z)VXcv_o+X`>JFZwCk-y7n-NC%~2l`D)Jh-fS?wo}>TDDtf zNn5Xe)$YAQh>f<~sS>rY4G~)t$N!CLdYO0kl(hk@I7b0^VOJ!q$L9uEW6VF z)2EtU`s?1;*1nFvdh5p5+FJ*{e{J+_Is5DE(x~zwQRh39%~-K=;Gxgn4W}0Wu9~T> zXtT}K>e(yTtKTc%{J2{;^~aB^wV#THYbuT5l_uUcFRFRk()73~^7vHCS#SR~)!uzw za#er))7j@wuGG{x5MHIVWZcg;C%4?%GCe7`Lv+-#1uac4ng(*0zp#HYsp0*thNjoe zc{Sk+JC#(Ihy5O&IIDA4wL#Ob4s^f8(ZAxn*R9jq8yhRCCeLf9TNzWg(fgs>6III% zPEiU`32)zXEp4>*kCw!$8Qfem^6H)1v+w#Decm(e*-T#CAQyMT5o@hfBDYy`k`iy6 z{4~0zsBm0Qjq~Qd>gK&X07?NF3uq_5V(_PfIu?>{)*^n=3+NsoX@ zytiRSrP6&gyT(f04DpqI*-_V6-DXqA>y|4cCxjgiRu~*_*K7QXboc(j>n1O5mMa-4 z>ztB4Z%09GWbN}q`9pSTx~&Q*GLs+w6vBsw(BeXJ?oR;!6*k7zr5w|;L+xgcvL6KdC+g#D~SkCQQHp*mW zhuf?K3~mdCragdZg`+KV z{B;&rTWlPXGVNw`y;fRTrQ^gXo1=kyb`E_}=4Y(-%4){RX+^Q?qN5KVOnT*b;bzy1 z_p;Y3$!Xtpx_M*8_~nB}$xcu`&OLi$`-plwRewp+#3I4N~Y~` zi>ZVAnWn3c9w58*oqfHeqjU0l*PK&}uJ1n_K4Jb2z2}Ei)$R?uIiV(^@4^>l?|Qy^ z&v`cFz*g@^ZasGI&OckWAai%4LAQ@*uN22eb>=qyuv(O3_GD0F^q7kJt<@JLj{PwS zxSTr8#<}}?Keuh$v`?ike3TS3;rSSD$7@>*0wx;oTKvUvyY{mUO5R(iE>X!cN;Bqp z#q}}Y{^t4d&D%GGKJCJd*i!sCY1mEmUp=@t)#nar+Sey~LHW(%$lx#P)8dYBV^_>8 zy}dJjYLj#RmK}qXdNp@0dVA~R@m}#dOSi-wu}lw^DI6MR>0hUjJadm3_v60sJ>lzD zoxP|&})&y)}DGTmD!ME zdl6FK(?%<^@uOT7dfIGTK6pi^jnB8AdbaI(VQscTg-X=MelD^fo_aQ4KTxhbvFY2Z z(1dqS?o5szxX#-CUhw|>Q-x>Fo?g%+}%k8$rMmE7o({%==OU-Bmc889Y5u4C@>w7E{MjSFB|BPcqpMuy=HZT-TVB{N-{w}DvB0p! zQ{|b=vo2O~#(`PJ{#KHTemCxNu5=54!sur^z#- zyJ_p2#?F*q*L&I~4fDgwQN6Xh*mvU<^cv-CW8Tg2rJKp^gIPRZh50-DqU`k@H(HyN z@-k;lwbB2RXn#&ZrYYThVsfy)O4ryi_DKcuBQi@i4X%EDF?Xj`rz;hP+qMn7S9W8L zMsB>>JMYeQy0LZIv$Zxw7$){M&06!ya{CVRDQoI}s;M7d*iAbt<#pO>Uis=@Rna#k z3ckGZ`=D;dyW(@dGGgi4uI{}f2PtW+czP#l>?EVo#Y=NFvezGp%dZPOWz*nmre*JuTxyV*`Qg-vt<-5z5 z=qF|M$?TkQ+}xtN{pt<@g_90l*=uGRv_J6GOZWDw1%G1AXAF(Gr?lxoxXo~j-UWL! zZ#=(bU>M-C*tmIU+=damUcNn_lyE|-hu+lCBY$2^Hn=$=zc6~iOO<^aIy}=U(A@a8 z@U*o<*Oa;^shsYab2?{D5BU*mmXqMVqm%v6i^sAJ{WA|Plim}s zw(#Zs&b-x1pK=R#e=OYo{@oZEtN2k9e(cy3G2N!_kEKEFl$lwz{xvSM`@X#P;y_~I zkLhyWb3YkWXB}HGF1gR6En9DXZ;`uEGu^i6u#4V0mn~Wo?uAt?J>2WWj}dnt+}M7g z?ESD+@jYz*N~d3Lc>Z<9vd6!VFKzm?+-2N@DQk|5vVN)L&=_%MPi~0)<`u&oPqe?B zJItcu+%+c^m!ZX}>2FP*jP&wmOH#uA!Hb7xtJ?t!{xv3GKKbRX?6g>Og{IP5e?_PxSqi3VP&$NzF>F*j} zA0z9!yuRnr5JQRI<$pZ29(mrboblLhd*Rx3UQJ^se4pI;X#G(AGef_(yo?H}SrB3@ zbvpR-)1t-?IjWMQmupbYRSiy z-Ol@bF6Z@^UV8dIIqG*O40a7VcS>gatbp|D*nVF>^$I>|-N}8^bz`|5Q)m5M zJ8*tY{Re5AZnyV*8)R}&X5Nh)i{Pdizh?~P{n@6eGkA(ON`mhYHZ6tN`!NMu3!8yU-Y<9~)PxYEf&BfGb`%f7K`(qV2j zwkIxF6+QiRW_(lERe2jrbWPM%licHyziL+O)RY{brymhi_Uo6(0jnpsr8=naiNJ@16R{}54Ic6(ge z(WnaJ(szwVw@x&hw4%JARC(VyQ^kHx-G>-IQHrYMZ+vbvyj;J$M)oh4tFe%ujlH1J%=;%`aYoStc`@983a zYm#C|-@H3EQ$Kz7)HnK;I&$Z*)ArV(=2LhZ?As@es#fVb+t|1I?962s?|MA^IBQjp zPba$l<(+Att6X1wpHaQ5H9hsVw@B?MoTV)=iT$uifI1=taN-i^-=g%xYqh(T7 zw|=aWKu!}_6m_Uu1$ovctI$d<#Z~v~^6u=+D6c$nPBmcBzSJpkiVyNnXItkqIeIE* z{@v+!pwA13<)y0I`n`YKaQF1|6*m_~Tj|>^w0$Cs?spXuUgd(+FT)9qhB6`yxm)}z$xTUz4% zi*~v8m#6LDx?xsGb6rv0kDlForOa;Jws&ChLhJsfOZ#>z5QLn5KzdMfKbA{V9AgZSSN{tZx zk@H?Jjf|;^n{2M3keQmiQU2W;!<6|)uK7*6KOn(X;?4KK+6H$sUGAB82gj`R?VwY7 zXT{i$qa^dc@Mb7*cb|SSRX$?R+Wfe)qhgl6x4P+;f9R8C)`?pRRfF@=%AI}YOf~6j z8=T~CF;}8FT1!T2n49-D%hJ>&uYS^7ZL2o$R8Lx$-?5$>P%v)MXSuQG1_XCFJv-3p z#;s0&`uyGP=d;Un(4vfoYI@G`a}P~eCq4UfFX!va7H*%C>}r2SPsyZyjiI}9$oEZ8 z-*|oM-qP-4;gek|dHu#Lbk2AfEFJPAF?^-Tk{JV^jjddu(X;!}scNRBTN4HcOu2V5 z?B)ECHH&*oF8b4=mGVq+@fzI#qoQ3tN{LB4pJ7iDj;#xi`8MThwu4`0kNV*~&30rB z_M32iWUz}%@z`kT^A|0Ce)LMcf4VHcxOt1ojO6@VxnHgxO7ovIu(5r##<9-9yL`v( zkhyS7%ksL!k2QuNvL?ZHk@xk!r$)>zOqlRyz?>zuK2OVcjdfC7qH@Ikg@fAo*&m)d zJhL2=k^7}SH#^vLgxXS@clG&2sl%@hRx;F{c4lRcg3{!EZ&fqgZ;et|Q=r$!p~IF} zGJYGIi>A(KezT<{TQ^^Fs6tWa`>IZ=<<}hCpW80iT~_Yg-11HS>ZMsX?ha2L;IQ}0 zP^*0w7d31=4{wT`_}X-RufWxBPh34Lw_s;bL9wQ3NM-%!nbZ4?e(R_H+4{M`;5g59 zyKftDpH0zFtK9G`!thzjmpQkzQawt3m3RHRW6tc^zuTpsly(*P&0!}0KFE}q&6JqO zlz4zCF&Rz_Lh7L7RnAtkwdJKh*H+s;U$U)o3TNZwM(g^`GLDIJWLK?trE&0JVV2bR zrJ*)Ge^jU*{@KGOWc05uyGph9YR)?#y{oavrc7>L<=*b&%H|ztuF2e0Ha@*ydVlM6 z^Y)&dvhb6Y{mAQnS-nTfKbFzn6BjdJ$l|;eGSwRQyI%;8{VJb3JyA0Jyn0DpaD3{p zFFWjW0uF3Cks-hSN9rzh{qm}rn%7H>_Fp-6nXCD^x9m2DJ`VG1)Z)9PkLqdfJvb%I z>rmdag0$WrFB#Zo)_zg4Eh^qPYem^^uc4!lf4gIP&Bu6J&XtZY{YT|Je>ZOI%fk^5 z8tUKH|NQ*2^j-P$`Xx&i1$=cj-JkG9vfnWs>*4Zay07+bTrob+(R-hdt-Ss=B0H-|ZPUASlZf*NuAi~_WOuGC!BpE= zd$ZoYF6BdH&uqT8&@uJaG4tbpEqk8am{V=~_=noGVQY^E+kSU8ukDp!vx#%^e0%56 zjyaQ6=9@oDY}|ZU@Al8XBbtgUb=+!vm2`4zch9mdt2fK$J*YYoyJz+XUZ2^UCAM{W z(<40pa)9~pbyIVfj#hd-;KkRF$WZM|r%$ga+H0m}``GE>w5}Qo`;C;eO;b*lZ#-st ze9(G@M!T^2SBGrsFivXulb@51l}=f{%JUcX;I0zU#AIHP&{ocyM+8g~_V<8~pNP6WpUm9CV77wJ`GI{N5bAw=Uvp z+Hu{>Kb@jq>1Fv0N(q;r7Upw&opz4?(HF7#8&4$ee%iEGdEM0_iC2eCLk0)^*G^&= zlWp#R6X7ci7Nv7vb+InJ`EulQ9gY^KQdg@=S7*9bem+P0C`a!uSD(W@s#U3{rJ|zz z@@mh$S1(_dk_h}NHwrETDkL6$j$u3aIms1s=9g%7k^VgIwc?V2x+z{WPskN0C{B3C zacV3t_x`b~Uy6yfPlCc%*V=fsznwcZYJMLiKhbQS=AGXyzs~imk-NWpd~(V3FVD)a z_OG~oby$_^X%*hx*nT^^8$FipIApI+Q?%r~I>Vjo2DU8XMY+_(S^YIkim#lvTba`{ zRkhsL%Wd;yxR43fkkj>f`?1C#Pbt|n#z}dP4v`w)jwrldXD;Gb>|N! zbWgZ<$R06a$BmX7t4H-bAq}>T~vY+V(rFbLXnf`=qmu*=WBHlkSsp zYvdSvhrl5P!3I`+U(X&s;acMHp0kY}yB2t_Tz}+R?A@?Y8xI(V44YSVMRn@|o8Rf& z$NGT-qSyBRXcsWR+p@=N->(tHry?7be;vqQ>*bWx^KoJB`#%z0mQ)WCXmSm#$=%Su zMd{HuZ^*1P?jm(3{LP3bz2e7sPrNn#)Y~I={@d(L_W%6(*HA-ICO}U=_DM;xu~PR7 zvY8JX8ycF+8%iWmH4}_qXwk z{YPHpY?{1UVshPpfTF8@eLj_WC)|%%doHC}#XQcY``yHzUstJ>w9|HIS3PjT`OX#J zUG~i`D?K&C%~s?G(Ls6gSZ&v^46*WaFh7E|E7U!(NSsM6-5 ziGv*b?;5mbgjaA>%HQ$bxriq3P;XMXtjKA=7#+4PX+U<3(^Ei38=D`g&)r~THtL}Pi zk=Y&lR#)}S7>n1EuU3V6>Sdp@x%_0I@{e}MVrNU78(OS0>xAO9opsm4qzo#m{q>P zFHYW-THi&bF($vT{q6x~awE0ZNo=3rKDfPS`#S^V7q)lbKL1ej){7GP*`p;o47NCD z{6l8nl`J`j_<&%UlXt!3?%W(-B75O$mYme-k4JZyMXxlB`~K9UY%3?H=hniQjteg= zlNz-0t#ORhrK2k5OS)+;P0JiO;rfu10!>eXHNBGWWT_@0C0d=#?>7!V^2w~dh01f& zZyiqmfK;W#xW%z0g&Eq(o;4JG2Wn-!|_3{3^|T&T8BF?nS&E?ls5zZJs~3OVor) zCEG>w$FA;|8+*@Der4K&z+BCbLmIc=m@3~z@$dI8K95d(SF!P%)W}<$RM8X@WoMMX ze`!O)_@I}|QZ4P3awjfKO+K%C`02Ov!}3RE-#HxC?z_Yk4a)(~)*fHzFjo1|T-DKi zd%SP)IFvQ3gPlb}c+~#rnj(oWue3b`NX%g*DbPPC?yWu8?V_=w+4qmr4-Gqay6i*s z@eXP$RQ=NI5@Y@>bDd@qBXe^rm&a9%^Exx_Z19c$ zX?<8@`G}*wdtVN>OT9CDOUdWaKRWL}GCjoZyM2?~-DRmg6Hc$!wcK!j)2|J0YDV{6 z|Lx;hH;ta1Eh7|b_T}};dNJ|+?M~WPhRr<5J1US+8DpOh=RZ$(vy(h;?%M{e5G<5$-ge>Y|CJ>8|CqqfRqyNAqqrzR66BUL+$7P$*?m35QUH?qd z-E-ujQA=)8{`AKhaUJW|Ygnv4`{@2Y!>qIqnP(23)RnK7YyPpWr1HkP>)$&h4|`vp zYIeIvi&LJ{L^W51w0y~>UbkQBKUth@`Rd)h`u&TXHmbW-I$hR_({uW;I@i#;E@Y3f z?TkI$E$6OS=J8c0s>~qet!vAjn2bY1$7X)#Uh!SHs@b{Eg4pi?s|T-;{CIELwRFA1 z9T%_KKh<~RE#AVZ3d7vGN4)zpv3;ecjpo#zR~5pGbR|?>5t0~%zQe$2@#t1OXhA}MWN6C$2ZQE;iiK}g#9yZDBgvZs>Nin^9%lAkc z+0N1N-2(F#`DMG-kI0xU*`p$$?AMxxD_0)6o!-@by^-{oNi!v+@=emGe%@KgjrlvZ zS4?t@LC7w}+K7nipk4ZjHw^mSi|&^osqEiNB59u2_i1yi2L^52P?Yrk!1w(APT^g1 z+VvP#p`)au{Mz&En!Ey+`#oQE37xP@P<`!_F*v`B7T5PC?KH|Lgf*^??I z9l!9L>Ps*E=4{=0mFMMKJM+$=@6!UfPH{2!RAd~>=3Y4T!Sci9#{I*O&p)Kl@%}OB z3*9Y0Mc1DUD#)Gkql2gHhd^1w#a~{XODOy~K(12Twac?)j|<)h6$~}r?TfMq)vbS- z`&>Ma%uQ>gTlm)WKd_=gcgVj-TF9WYRU&+cfEu-Q+8G&6{i8`i|_N zvbUqmtscql+ns4u83IP?VJ#!x1UVCeN+OMcT z?Z$_8c_|<<7AEngBkZy}w%G>tyW0J7*+JV2h0jgy|E)VW^+@*z(pM}zcXjEYY^>!| z?x13&m14WAZrhHB&m)fe%Ba|y-VDkqY`VL|Iws`j)t0|c|NeOMp)g)mRsk`$O))Rb>N-fzO(vLoOK(cinwJ=k)Nf)V|{tRC}iRj<1>1&QU*O z;M1K;jmG|2+O%j{LsLxEZi|=ma^4(@N`B!n{OTTso<|dSGF!{kAMcKek=*`Z>JXC! zW0iJltl6_){o-Mb>fQb^30r1n$ENL(c!ZYJhW9QWl80wXqwGy*^Zxy?k|Ivq`d^^4m)q zC+{v)8?b(st;CE?sfsn-`%jh5@OdodX(XSS@$+quA!{a_wB@e0>X&Ul(q+t{x3ZHL zs`XwkV{@&C`}&PKN@e}We(H4Gd|1wGi@ZUTOq%^`U+mwwug{QvLzK7t{(SvW)TV{% z%Ijl>g(=*ysInZbJz)O%#m?5p+WX3AB)W#y2XsrgAp2I!ZszOI$)^Utww{|NqqW}S z-s1ADyKB{I3sTnSj8uED!N+(7XG1)%)NsYx^2^rCq}|Pe=bk;%e!unFqfgA_lxqvk z^s-k4JWuzWm-0#Rh2EUADkeKFN-v5?J7sOD7Jfrn?%|fu_eRT4SxZT_T+^96>V%K+ zq#oP4MCnvN+!FD5hw+m)ezl?Ivo0Myp7`)?hnSd<;L+=DzmfoVr?2IUW@>1&UaWTZ{vC8+7N@qef!m(nwnuhw9&fw;z_^Q(_?=4XFLCtJkn0Ron{Am1x+x2%P9#?NVTb5@zD+10A|yFFjAZ{jFZ^UsngiaNVY?VgVd zEz9t>^mx}X&ilp_$zJku#%>o?;})GT?|Z~xgL$U`3*Md$d3|wryJ3$ejxbVtscl>} zd-?J%x#lOVV(sqE{qg2@*O{HQ<&S)x678a2zWdtw=Xri#kKG z`0wc*n(xcD=+$0Pdh>U3cGlslHx0wnqtsT;(Qo|Yt?ZmP{`aFer`Wg(xlsX{gQd=$ z@judV+1h{C0<&)>ooal-%r~|>yeet)C&NkKRr}5@Uu5j(TR61wgJyBq$_qV{I_6iX zu5hS3^HTGUd+Om+A*)Ihhc#@Pm3`agNKk(DxIS|XzqY(<2nxtwRI@=(b4YOb$0aVF zr(M?`-5~pA|A?-6!CJ+~r^v6=-|nGVoSnP!W~GTz=cCn*E9cI6K5Au`+N~ovXAYlE z@|vlwS8Ue7{mrwec5zPg^3J-4#jF{hbn{?Z^6Pop??-K^NZ7EvboH}oj*3NDX*~xo zeIVVj)W72@y;Tw8`j1NNGI4FU*D^uR?q$19vE$ue;Wuhb){w96owD8b{M4MmnW3#y zoMD^0NsTwI%)`mW`@5yGYev@qr85d)%HMiCZ{JBj^zZw3U$*=>u+F;I+jo~@IY#$4 z59(jIvajvw>ZRs!ic21?4`}b49^id-!#QJp?F;!Azt%`Sj_xo!`cl)URlyzi+0W;U z9(>s}YgM9Oje4*B@<#P9Ju8y;x40ba@wxJ6Q3m(6vORi+P!r`6tJb%`21LR@x*_HG>~qmwgVG{@%wOF+C(~wARae8C@89=&-D8yW6O9y| zh?J39vwQCF&b;}tXT0jbb>q71@~e9K$0=jf^4TLkDHu%k>yi@{bz^mL?V#j8BQE!@ z*{SOa?;wGP7b0Nk4@3W;vG5I6-5}}B?guA)JUC;(vf|*?J5_#+pR_Ua-6W~-mBzMX zxGRV5OFO$yZKRjmNc+)G3}?-=a}2E-(GX)8$#tv`J2yltea*=dk6W8P)t$n+t=O*g zJ3)W`lsMJ$o$inQ2hP1YV0Pj1*{P3xVbp)_Rg@`dl&Xh$f-BXDhN}WZ+yJ5X;)s~>DPHR?FMvrUIWVx2_GmFKH!#c zitJ-WaLte{R$sfQcI!V!M&p4}d|k6g`1&-NlP$kKN>_ex_PN<^n`#F|Go9i^gX7kC zSo!eMJjRa?YN-kfUAAntWFtY2N1CVOFcNY&5W11p!m-jwdXqHNFb z!7bO%Y*5ZUzeLe8@Zo_B=fd29kCz1uU1YOIg6kN;DbX2yUv6E4&fNH>ql#sL4-@`` z9&B1*dwW*1 zc4I6~=E=-izE3avL*3Bh(?X>m%giX0*kdHeecu$i^xlW;mbAXhFJ}!_PC8;_#jU?< zWtf>cATegvowenChwVA4v-i-`T~=)%-C~E+ zold;ZQ&!FJFRSpsTDsK0KWBY>C2!q|B8$J#?e`Xq`yj2;>-^uevqL4_l|IZ&3+VUZ zM^*ayjn`k_j_7IjVQ*)XBfWf2Brmq9{kHMU;`tpO*ic{Xa%`kJNrR!e#kk@ug zWPJW!iM{O9%lwOM-hpo)1Uim^b?guQgF2D7w7ade>(c%Xk7vHn%(Geg=kEgd4-b-? zceS6D;aT@VqGadGnFytx7hMiP4gmdVgzph1JE!w^V(+x_Qmu z-N#i6Ow^lQwl^Pjw5)FSRfqS}!ER=WSSnI-!1cne#VTE-iu-0-40Id&_rg)nzdTj@ zeJ|=>UU}G|NBc1kG!IQk9kR{vW?)8HW>c>Wi+GO)P ztBA;~AAcm5p6;tDD8m#k!wT_+fRr1S?X?x-?r%G=^_)(+v(0EDsRg@NXDDB=HZJgZ ze=JPn#pCUFW<9+G5afw(pzj>ngn^EBZX^5-po^BmUS6NjMl79y7Oqka9J0gtogJh3)R=f6knUDI@w0yQdFIkYSs3Z zd&@iCU$j1UTDHNnlEK+c&IWST=WLsT$9Apu+I*2W?e?r~`)B$MC|O##A!$#irIi)S zjwuGkEV|P4%Q;VR!DjcyP5y7rUz;@~c9CRp)ix#Eda(%H}7fVz(%pT<;X|j#gS36qSLPhrxWzJI(~M{hqoyA}N`hxxo|^u9NRMURBUP%B9rPCrn8;ak zS8nRJvtM>Ci|$!c`E*nGov!Ol?Rs6Evmk2gGOY`1_NrJ0IV|0~N2@H>;;WU)QyGg2 z)w{3HkMU2KwjlkP_P~ZL!>$cFDcRmrhdCKLDt*r$(|Gs%+nn$@I}!rJr-wy54(|O& z_moUR&bb=>WgV_&j#XUJ&3Tgkgt!e(mRY^1Xcel{b!`X>IDK*C>AJ6|-5lRi;diW>UiHH;AX^bvBSKV&L_!Z>fWr!vyuA!fj*@@NaE`Rp=yU(RTjWkpoY zf|?Jyr~t~S5?TBHK2u>RfLWOvtkPW+uR{xT!kqW6{D}s z$-mrGgn5ZxAgtCIJl)dVn<tN6G54uqrpgQRbD;4W#Z_W1@%&4F- z?80rhY6H8rcd8{iZK3LR9vULDu#6YZVl*FRqlD##ZzK(c*lW(d<=f_jhnT~pE|F$x zB}6(O6s-r$`%in(`jUH21<0`q1g4=9?n1b#5c}pmdh1s01ylyE=#Xt{D7M5#IBY@167nIKgZk3=^WP60`# z!>^il0cL{hKZJM>s+OdU<_LQ7is1Q9P#n`4|V}bit#3d|RRYZ_Q;#Ic_kK_rb&|k@t{? zw>wg)oKczrcAJT?BRl&Zi|4AZm0D&c4hVgp4eVl`jvo9_Y}CHlpjoSlFj#mrhMtyj zKo7vHNe(K0*f}r4h0-Mj~iGng*em-x#ae2=(GE{(oGl|GR$+xPD_wBe@6#g zQ|cDVFX8srq{#XZK^?>)#;{b!g=WI0xweqo<)jaHMN0;>wFK7rqo=_}hG-LMK7H_O z>M)qQ1U1RZR>O&U!m#sp6i~ZQLS?^5of_)cU0Fr6;pakVV2UmeBm4!)WH5y;TPys8 zlrmVuSHecW@lFRT{JSe{tdipa3t$qRJQlf|oB|fP+aL?z!tZ0b_t5iT;Yih2s#qoW z-~T0b7+U4trc}W?-q$tyq0*^h9Y4#;VVSmut9JSIXZZuhqo{2v0-u=rb(+@Ab_IN0 zU0)f76x{9ry7YiO*I8@81OTT<5n&TTK@`_tq+-NHVXkB{K@FF$m8~R2PZxv1kk+?A zHKlsz$|McZ9TY8dVSz&+DjbRlapd5{wW45{=^0`d09X6)(Lw6`8al;=f)JzM#`o8} zyvemrhZVArq*zXpMaeKgj2!P(Dpc>U9_@PqN??XDN-lZ3VwR;DCkn;&xd%!3>$R^Wjdq7SPe;D?fLd028tno&v8ZqS2=R4 zYAw937$$MivfQ?Blb?@KKGRw>9?DG-6di@k5ljdB+CX+~LM!P{^$n*^H!^pi(g72u zGIJo^pI#*y8fXT;)3>nxvct*jc*0yqE^FvCSECd#&`2Gilun!5)r<@GRd(Y%FZ*PK z%&8Iw_*1Fd(Oi1(bp3caWRbC(8rc*q1-0ql$uh=Z3aW%Hkw)Ur*$bt}p5*J*lOC*Y zTpp$>{_fPNTReK1SH#1gY|PLck&Ti97xY}km8ya5eCitxCfB#e{Nf$q!=4rnSBD}x ztOUk|RFc~eJ?n~!c&K9NPxzicQO!U?=%|)jW?&JIOYJl*5Ip`?KUhIGX~;7+Vk{!$ z;_m?}?)0V<_bym)MhE5hlvv_>mo8;A-cKQ@$4<;DkKJp2RB`U1V(1tCmZQwu9af~U zpZQVYUA%9o`)$S|cI~o=9HPdDcICew3>D2*LtrQ2_3Z`T_tusFRM%7EKOw(?54f)6 zL8l6{!r+1-=Ef?4qrauO4xOWyQOdN_KU0qb55IUjh4|Z)7w!$Nxh-(b&f3Eh%;3bg zJChl)(2W?&jxB{?wC;CZAqIf*cx4NA1qiu5?Yqoj(H=6_=Dx(D-Rwo({E*tH)t+xR z6StirSWb^7-&R9|M5W`}=c3YVrMzj7cu^iOA+HURmk+HUbo9qHSA~cLfPgfG+B3TSb9A$@A$hEc(V{$hO zd=tDyIjCuDrqD6<(ckZnNU{pz^+gpe^xVuZX)>faf)d42+Z{h;+)*jB9#FTb@a~ep zE|#G(8x7AhH#>Lr6zdRuO={E-0D!Mh`lN7Pz-*MAEaX+F#8T-gE9p5KSW8_eKsnRVORZD2!$@8? zkpe;yO0)rO*@g9g4R3%m?TBw_2de$pxq-oTER%W*7H7?LeXY zH#Q$q1K0M~oFFH-&sOgbOGdTzfSgi%>!fbZ7-|UC~ z&Py**XF`cNDXKj&A5jW!g%iuM^76@*)e}M{j%!-_ zHP4G5mmDqbBH;X%7YHqlW(9m?SzPp6l8O=oCP9kNNHBxQ zK#U9r6y5b|q+24xUuoBTY+Hx5(YXUMeFCHVw59Zl~sNTp$S_rjHO7R z7qxG_zJYc?6OvtEC)e#AqM8a%Es3!Cl`*DtMhN^>SOTsX=@!QOJA8#SyxWsCv2~PK z^jlAZB9GL5GMMR}aQG$0dll@j1xhK%SNQ5}S@7V1z_?CGjW~J}us0I|Ck|^S5N-64 zwqJm|u<6m+h7i_%d(f47o%ujON%Zc>;|vsH1v!e+xJH`^IVAS3$kZkoO|2v%N3Ks5 z{ltqG0M5@Efs^;|aadqOR3n_o6NuBdkj5(^qBLCPPcffy#CTBvLKfPM*X9#p_eLC( zo15{HuL)ia)vFGfxE-iq@e~8>5cZKWs}hWyj8-cLPUcy*aU`*R?IMz-hjcL`a*XIM z2#j37aaGV!vx-f>ULLtDJ}RN~pu)Y>u494XUX3PW;|C-1N`t8SzNzf`K|RB{05Cba zZs~&$a3lL1p>{V=`RkiDLMFDH=VI#U5%#Rys?oOW_De|ZOH9y7KMkOzv*xum=ne9g zs2niEhe+Tfh37y;f)t}x+R#!y)r8anZsGmf>Hpl@ zx>Ugf6BBv-EgoA6Gj*pDtMJP*A1-#6I#Rf*H9FRY1UF0@Bt5TPUv$%yp@a_nR>;a~ zt-yprt|b9eE4vimi%MRMCTO6QbuZlb)ynKJDm$vuGwUo7vad5KmMP->LeORp6jqjx z^bf5PGah!TW*FZk9RCR=$tl#1-_xoLFYEh{S60WCyA)c@h+&flC=P+n; za3^D1YSdq~=(?Bn?sW+mkf+zZp&S(4O2B`ztWWa3OXR~3B(P1N&- zoD<=wJ$dHmW?$$KD#^W<$enSqk^-M5HGARO(#1-MGz{i74cAVwpd%^-^SGhAvx0SJ z|2;9`-S=7ZK$B5tseiv($ZSZdscSL(GSfRKoP+P*J7X$pVeW{Nj}+9X?bTEMMrM$rG{v_7X)Rn^!{LKMihJe0z?2Rw!3poR^T;2 z;YZ$6qB57tPM<}(mfsk}@+s-?_+E5J@q)B^JHHubO9I2TPByqSs8M}fLM2ULz4g%o zyJf7&V*+U9;Ln|Q&y`9~pO3XRa3W_Sh9HifMbxS6@~yI1+I0fQ{|m4VYATgY;&#_xHih;g5dtw?ln$G zW_-kit4Ko6ZK9F!juvf06d-6Yi5#*p*Cohs^|v|y3DxDtlHRKWip6`1Dh8z zu{%ZKtw<7EwzP4ho|z=dE}_$$zRl72b3N}AXDXM#$@ATQMyav z0~6RIhZIVPd=Q_K0Fk@mNxeG|0w^!L6K&G`3ZUX3`<>!pohg9w7eB!0d+{tVG5Ef_ zg&ye;6QV5*5d0mcZ9su!J;6=ocSO};Yqs;a=p44;71Wxo8+i{#(Sz`hIG#b&z`Q^= zP=p8?sgF){7CR;g3mTPoKm&|t6r}kfI|&^k#xKB0KnySbwOTsXHU-()G04OZ7%wcA zflNF*Nv)pdz>`U51URnxRBif>4bl z&u_%4^gVrRljN_wc%w^0)ayNnI!P2^F2ArObW=V0$>l7~ipd>X<5pt_9WKDIX6n+> zmcpkBA$&j}*W0bGH(33#)(y=~K{m>my&X*(ikqzfkUiC$JNCH+@jNvu0a#@+FtsxMI;V6>FTd8@vp_`q1D}u&Z62KZZ0Q5gsm&h*+5Lr#@ZCy0Bq3 zX%0A4>w(#t5g`*fZLAY!r92{R89Ksf0ajzOj6-AB?8kjF74=zDjf8oNBy!87jM;D2 zB`W0LkNV)@hAU%6)+@E72f^G>msP@&_Qka@!+^v04)nGSrR?39rzhcVVU)tV4w3HO zU9Cyr$YY?9GSCvz(FD_(n@0R4ElWz-R2{6cH6gJJ?Rb!m{Oz@GRRf$YZTtkO1(J;I z7!!_Klh|?@Uk&WGvme2xH(sJHUb$I_Yn8v|*`2w5EnmPmvhFSE>yzM?#HO$|q%_+l zbz}}y6cj;z5>e08YzN<;QIQY;rlOQOQ2jY_L?7%0Dfb&O)id(3mWmK$wWpQLMgxr> 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 W@e$$T^>lG@@egcJ1Z)5R0002_rQA*c From 3d92c4eb5424660667d068f8f01088a7d65a1774 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 2 Feb 2026 07:42:22 +0700 Subject: [PATCH 03/34] FEAT[BE] :refactor egg production data retrieval to use date parameter in GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds method --- .../repository/common.hpp.repository.go | 27 +++---------------- .../services/closingKeuangan.service.go | 2 +- 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index e0f2bcc5..2c8187fb 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -20,7 +20,6 @@ type HppCostRepository interface { GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) - GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) @@ -197,10 +196,10 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda } func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { - // if date == nil { - // now := time.Now() - // date = &now - // } + if date == nil { + now := time.Now() + date = &now + } var totals struct { TotalPieces float64 @@ -220,24 +219,6 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang return totals.TotalPieces, totals.TotalWeightKg, nil } -func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { - var totals struct { - TotalPieces float64 - TotalWeightKg float64 - } - err := r.db.WithContext(ctx). - Table("recordings AS r"). - Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg"). - Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). - Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). - Scan(&totals).Error - if err != nil { - return 0, 0, err - } - - return totals.TotalPieces, totals.TotalWeightKg, nil -} - func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds( ctx context.Context, projectFlockKandangIDs []uint, diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index 44137fad..ca76c67e 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -262,7 +262,7 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo } if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIdsAll(c.Context(), projectFlockKandangIDs) + _, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(c.Context(), projectFlockKandangIDs, nil) if err != nil { data.TotalEggWeightKg = 0 } From 9bf33d2bae0502528f909580649d6047242d15cc Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 2 Feb 2026 12:15:41 +0700 Subject: [PATCH 04/34] add response stock to informasi stock product --- .../modules/inventory/product-stocks/dto/product-stock.dto.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/modules/inventory/product-stocks/dto/product-stock.dto.go b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go index e571d2b6..be8a5b04 100644 --- a/internal/modules/inventory/product-stocks/dto/product-stock.dto.go +++ b/internal/modules/inventory/product-stocks/dto/product-stock.dto.go @@ -62,6 +62,7 @@ type StockLogDetailDTO struct { Id uint `json:"id"` Increase float64 `json:"increase"` Decrease float64 `json:"decrease"` + Stock float64 `json:"stock"` LoggableType string `json:"loggable_type"` LoggableId uint `json:"loggable_id"` Notes *string `json:"notes"` @@ -195,6 +196,7 @@ func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO { Id: log.Id, Increase: log.Increase, Decrease: log.Decrease, + Stock: log.Stock, LoggableType: log.LoggableType, LoggableId: log.LoggableId, Notes: notes, From 5b80081d05d6058dc3f070c617da0ee39b793e25 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 2 Feb 2026 13:02:30 +0700 Subject: [PATCH 05/34] FIX[BE] :add handling for empty input in ToSummaryDto function to return zeroed summary --- internal/modules/closings/dto/closingMarketing.dto.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 189ef7cb..421a8d3d 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -122,16 +122,23 @@ func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO { } func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { - var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 count := len(e) + if count == 0 { + return SummaryDTO{ + TotalSalesPrice: 0, + TotalActualPrice: 0, + AvgSalesPrice: 0, + AvgActualPrice: 0, + } + } + for _, item := range e { totalSalesPrice += item.MarketingProduct.TotalPrice totalActualPrice += item.TotalPrice sumSales += item.MarketingProduct.UnitPrice sumActual += item.UnitPrice - } return SummaryDTO{ From 760b37449e556383c3204b634f52142596facd89 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 2 Feb 2026 14:08:29 +0700 Subject: [PATCH 06/34] [FEAT/BE] fix bug recording and closing counting sapronak --- .../common/service/common.fifo.service.go | 20 +- .../closings/dto/closingSapronak.dto.go | 4 +- .../repositories/closing.repository.go | 121 ++- .../closings/services/sapronak.service.go | 13 +- .../dashboard_stats.repository.go | 144 ++-- .../recordings/services/recording.service.go | 718 +----------------- .../services/recording_fifo.service.go | 703 +++++++++++++++++ 7 files changed, 917 insertions(+), 806 deletions(-) create mode 100644 internal/modules/production/recordings/services/recording_fifo.service.go diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 14cbb5c1..190bf819 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -147,6 +147,7 @@ type StockReleaseRequest struct { Reason *string Tx *gorm.DB } + func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { return errors.New("stockable key and id are required") @@ -308,7 +309,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St } if reductionTarget > 0 { - released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget) + released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID) if err != nil { return err } @@ -355,7 +356,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) } var usageDelta, pendingDelta float64 if ctxRow.UsageQty > 0 { - if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil { + if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil { return err } usageDelta -= ctxRow.UsageQty @@ -721,6 +722,7 @@ func (s *fifoService) releaseUsagePortion( usableKey fifo.UsableKey, usableID uint, target float64, + expectedWarehouseID uint, ) (float64, error) { if target <= 0 { return 0, nil @@ -736,6 +738,20 @@ func (s *fifoService) releaseUsagePortion( if len(allocations) == 0 { return 0, nil } + for i := range allocations { + alloc := &allocations[i] + if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID { + continue + } + fmt.Printf("WARN[FIFO] ALLOC WAREHOUSE MISMATCH usable_key=%s usable_id=%d alloc_id=%d expected_pw=%d actual_pw=%d\n", + usableKey.String(), usableID, alloc.Id, expectedWarehouseID, alloc.ProductWarehouseId) + if err := tx.Model(&entities.StockAllocation{}). + Where("id = ?", alloc.Id). + Update("product_warehouse_id", expectedWarehouseID).Error; err != nil { + return 0, err + } + alloc.ProductWarehouseId = expectedWarehouseID + } var ( remaining = target diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 92d3b2ee..30b4d945 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -234,14 +234,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin row.Notes = "TRANSFER STOCK" } } - case "pemakaian", "adjustment keluar": + case "pemakaian": price := row.UnitPrice if price == 0 { price = item.Harga } row.QtyUsed += item.QtyKeluar row.TotalAmount += item.QtyKeluar * price - case "mutasi keluar", "penjualan": + case "adjustment keluar", "mutasi keluar", "penjualan": price := row.UnitPrice if price == 0 { price = item.Harga diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 04391332..d37ae6c1 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -36,6 +36,7 @@ type ClosingRepository interface { FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -939,6 +940,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` (sa.usable_type = ? AND r.project_flock_kandangs_id = ?) @@ -1085,12 +1087,72 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) } func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) + poByWarehouse := r.DB(). + Table("purchase_items pi"). + Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date"). + Joins("JOIN purchases po ON po.id = pi.purchase_id"). + Where("pi.received_date IS NOT NULL"). + Order("pi.product_warehouse_id, pi.received_date ASC") + + incomingQuery := r.withCtx(ctx). + Table("adjustment_stocks AS ast"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + ast.created_at AS date, + CONCAT('ADJ-', ast.id) AS reference, + COALESCE(ast.total_qty, 0) AS qty_in, + 0 AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Where("COALESCE(ast.total_qty, 0) > 0") + incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p") + incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err } - in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) }) - return in, out, nil + + outgoingQuery := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(pi.received_date, st.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date, + COALESCE(po.po_number, st.movement_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN adjustment_stocks ast_in ON ast_in.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Joins("LEFT JOIN (?) pfp_po ON pfp_po.product_warehouse_id = pfp.product_warehouse_id", poByWarehouse). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") + outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") + outgoing, err := scanAndGroupDetails(outgoingQuery) + if err != nil { + return nil, nil, err + } + + return incoming, outgoing, nil } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { @@ -1286,6 +1348,59 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF return sales, nil } +func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { + if projectFlockKandangID == 0 { + return map[uint][]SapronakDetailRow{}, nil + } + + query := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE( + pi.received_date, + st.transfer_date, + lt.transfer_date, + ast.created_at + ) AS date, + COALESCE( + po.po_number, + st.movement_number, + lt.transfer_number, + CONCAT('ADJ-', ast.id), + '' + ) AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(pi.price, p.product_price, 0) AS price + `). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group(` + pw.product_id, p.name, f.name, + pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, + po.po_number, st.movement_number, lt.transfer_number, ast.id, + pi.price, p.product_price + `) + + query = r.joinSapronakProductFlag(query, "p") + return scanAndGroupDetails(query) +} + func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { if len(productIDs) == 0 { return []entity.Product{}, nil diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 9501cfbc..4dff148b 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -363,7 +363,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if err != nil { return nil, nil, 0, 0, err } - salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.Id) + salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } @@ -570,13 +570,12 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if existing.ProductName == "" { existing.ProductName = d.ProductName } - existing.UsageQty += d.QtyKeluar - existing.UsageValue += d.Nilai - if existing.IncomingQty >= existing.UsageQty { - existing.RemainingQty = existing.IncomingQty - existing.UsageQty - } else { - existing.RemainingQty = 0 + // Adjustment keluar should reduce stock without inflating usage-based HPP. + remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar + if remaining < 0 { + remaining = 0 } + existing.RemainingQty = remaining itemMap[productID] = existing } } diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 0662a0de..6645bd9f 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -107,16 +107,23 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { var rows []RecordingWeeklyMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recordings AS r"). - Select(`((r.day - 1) / 7 + 1) AS week, + Select(fmt.Sprintf(`%s AS week, COALESCE(AVG(r.hen_day), 0) AS hen_day, COALESCE(AVG(r.egg_weight), 0) AS egg_weight, COALESCE(AVG(r.feed_intake), 0) AS feed_intake, COALESCE(AVG(r.fcr_value), 0) AS fcr_value, - COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`). + COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`, weekExpr)). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.deleted_at IS NULL"). Where("r.day IS NOT NULL AND r.day > 0") @@ -188,92 +195,19 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week return nil, nil } - filterClause := "" - filterArgs := make([]interface{}, 0) - if filters != nil { - if len(filters.FlockIds) > 0 { - filterClause += " AND pf.id IN ?" - filterArgs = append(filterArgs, filters.FlockIds) - } - if len(filters.KandangIds) > 0 { - filterClause += " AND k.id IN ?" - filterArgs = append(filterArgs, filters.KandangIds) - } - if len(filters.LokasiIds) > 0 { - filterClause += " AND k.location_id IN ?" - filterArgs = append(filterArgs, filters.LokasiIds) - } + standardIDs := r.standardIDSubquery(filters) + if standardIDs == nil { + return nil, nil } - query := fmt.Sprintf(` -WITH src AS ( - SELECT DISTINCT pf.production_standard_id, pf.fcr_id - FROM project_flocks pf - JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id - JOIN kandangs k ON k.id = pfk.kandang_id - WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0 - %s -), -actual AS ( - SELECT u.week AS week, - pf.fcr_id AS fcr_id, - AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight - FROM project_flock_kandang_uniformity u - JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id - JOIN project_flocks pf ON pf.id = pfk.project_flock_id - JOIN kandangs k ON k.id = pfk.kandang_id - WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0 - %s - GROUP BY u.week, pf.fcr_id -), -target AS ( - SELECT sgd.week AS week, - src.fcr_id AS fcr_id, - AVG(sgd.target_mean_bw) AS target_mean_bw - FROM standard_growth_details sgd - JOIN src ON src.production_standard_id = sgd.production_standard_id - WHERE sgd.week IN ? - GROUP BY sgd.week, src.fcr_id -), -weights AS ( - SELECT COALESCE(a.week, t.week) AS week, - COALESCE(a.fcr_id, t.fcr_id) AS fcr_id, - COALESCE( - CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END, - CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END - ) AS weight - FROM actual a - FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id -) -SELECT w.week AS week, - COALESCE(AVG( - COALESCE( - (SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = w.fcr_id - AND fs.weight >= w.weight - ORDER BY fs.weight ASC - LIMIT 1), - (SELECT fs.fcr_number - FROM fcr_standards fs - WHERE fs.fcr_id = w.fcr_id - ORDER BY fs.weight DESC - LIMIT 1) - ) - ), 0) AS std_fcr -FROM weights w -GROUP BY w.week -ORDER BY w.week ASC -`, filterClause, filterClause) - - args := make([]interface{}, 0, len(filterArgs)*2+2) - args = append(args, filterArgs...) - args = append(args, weeks) - args = append(args, filterArgs...) - args = append(args, weeks) - var rows []StandardWeeklyFcrMetric - if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { + db := r.DB().WithContext(ctx). + Table("production_standard_details AS psd"). + Select("psd.week AS week, COALESCE(AVG(psd.standard_fcr), 0) AS std_fcr"). + Where("psd.week IN ?", weeks). + Where("psd.production_standard_id IN (?)", standardIDs) + + if err := db.Group("psd.week").Order("psd.week ASC").Scan(&rows).Error; err != nil { return nil, err } @@ -635,21 +569,29 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) { var rows []EggQualityWeeklyMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select(` - ((r.day - 1) / 7 + 1) AS week, + Select(fmt.Sprintf(` + %s AS week, COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, COALESCE(SUM(re.qty), 0) AS total_qty`, + weekExpr, utils.FlagTelurUtuh, utils.FlagTelurPutih, utils.FlagTelurRetak, utils.FlagTelurPecah, - ). + )). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id"). Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). @@ -670,14 +612,21 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) { var rows []WeeklyEggWeightMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select(` - ((r.day - 1) / 7 + 1) AS week, - COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`). + Select(fmt.Sprintf(` + %s AS week, + COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`, weekExpr)). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.deleted_at IS NULL"). Where("r.day IS NOT NULL AND r.day > 0") @@ -694,15 +643,22 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) { var rows []WeeklyFeedUsageMetric + weekExpr := `CASE + WHEN r.day IS NULL OR r.day <= 0 THEN 1 + WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17 + ELSE ((r.day - 1) / 7 + 1) + END` + db := r.DB().WithContext(ctx). Table("recording_stocks AS rs"). - Select(` - ((r.day - 1) / 7 + 1) AS week, + Select(fmt.Sprintf(` + %s AS week, COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, - LOWER(uoms.name) AS uom_name`). + LOWER(uoms.name) AS uom_name`, weekExpr)). Joins("JOIN recordings AS r ON r.id = rs.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN uoms ON uoms.id = p.uom_id"). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 7a63d5da..c5537b53 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -21,7 +21,6 @@ import ( rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "github.com/go-playground/validator/v10" @@ -40,14 +39,6 @@ type RecordingService interface { Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } -type RecordingFIFOIntegrationService interface { - ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error - ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error -} - -var recordingStockUsableKey = fifo.UsableKeyRecordingStock -var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion - type recordingService struct { Log *logrus.Logger Validate *validator.Validate @@ -89,21 +80,6 @@ func NewRecordingService( } } -func NewRecordingFIFOIntegrationService( - repo repository.RecordingRepository, - productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, - fifoSvc commonSvc.FifoService, - stockLogRepo rStockLogs.StockLogRepository, -) RecordingFIFOIntegrationService { - return &recordingService{ - Log: utils.Log, - Repository: repo, - ProductWarehouseRepo: productWarehouseRepo, - FifoSvc: fifoSvc, - StockLogRepo: stockLogRepo, - } -} - func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -347,7 +323,12 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } var warehouseDeltas map[uint]float64 - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) + if s.FifoSvc != nil { + // FIFO replenish already adjusts egg warehouse quantities. + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil) + } else { + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) + } if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err @@ -529,39 +510,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := ensureRecordingEggsUnused(existingEggs); err != nil { return err } - if s.StockLogRepo != nil { - note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) - logs := make([]*entity.StockLog, 0, len(existingEggs)) - for _, egg := range existingEggs { - if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) - if err != nil { - s.Log.Errorf("Failed to get stock logs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - latestStockLog := &entity.StockLog{} - if len(stockLogs) > 0 { - latestStockLog = stockLogs[0] - } else { - latestStockLog.Stock = 0 - } - logs = append(logs, &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Decrease: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: recordingEntity.Id, - Notes: note, - Stock: latestStockLog.Stock - float64(egg.Qty), - }) - } - if len(logs) > 0 { - if err := s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil); err != nil { - return err - } - } + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { + return err } if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) @@ -818,40 +769,6 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } -func (s *recordingService) logRecordingEggRollback( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, - note string, - actorID uint, -) error { - if len(eggs) == 0 || s.StockLogRepo == nil { - return nil - } - if strings.TrimSpace(note) == "" || actorID == 0 { - return nil - } - - for _, egg := range eggs { - if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Decrease: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: egg.RecordingId, - Notes: note, - } - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - return nil -} - // === Persistence Helpers === func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { @@ -891,381 +808,6 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } -func (s *recordingService) consumeRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - if len(stocks) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, stock := range stocks { - if stock.Id == 0 { - continue - } - - var desired float64 - if stock.UsageQty != nil { - desired = *stock.UsageQty - } - var pending float64 - if stock.PendingQty != nil { - pending = *stock.PendingQty - } - desiredTotal := desired + pending - - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - ProductWarehouseID: stock.ProductWarehouseId, - Quantity: desiredTotal, - AllowPending: true, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) - return err - } - - if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { - return err - } - - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, - CreatedBy: actorID, - Decrease: logDecrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: stock.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) consumeRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, - note string, - actorID uint, -) error { - if len(depletions) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, depletion := range depletions { - if depletion.Id == 0 { - continue - } - - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } - - desired := depletion.Qty + depletion.PendingQty - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - ProductWarehouseID: sourceWarehouseID, - Quantity: desired, - AllowPending: false, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) - return err - } - - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { - return err - } - - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - CreatedBy: actorID, - Decrease: logDecrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - destDelta := depletion.Qty + depletion.PendingQty - if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - if depletion.ProductWarehouseId == sourceWarehouseID { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: depletion.ProductWarehouseId, - CreatedBy: actorID, - Increase: destDelta, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) ConsumeRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID) -} - -func (s *recordingService) releaseRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - if len(stocks) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, stock := range stocks { - if stock.Id == 0 { - continue - } - - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) - return err - } - - if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { - return err - } - - if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, - CreatedBy: actorID, - Increase: *stock.UsageQty, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: stock.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) releaseRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, - note string, - actorID uint, -) error { - if len(depletions) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, depletion := range depletions { - if depletion.Id == 0 { - continue - } - - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } - - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) - return err - } - - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { - return err - } - - logIncrease := depletion.Qty - if depletion.PendingQty > 0 { - logIncrease += depletion.PendingQty - } - if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - CreatedBy: actorID, - Increase: logIncrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - destDelta := depletion.Qty + depletion.PendingQty - if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - if depletion.ProductWarehouseId == sourceWarehouseID { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: depletion.ProductWarehouseId, - CreatedBy: actorID, - Decrease: destDelta, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) ReleaseRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID) -} - func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { if projectFlockKandangID == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") @@ -1356,212 +898,6 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } -func (s *recordingService) replenishRecordingEggs( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, - note string, - actorID uint, -) error { - if len(eggs) == 0 || s.FifoSvc == nil { - return nil - } - if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { - return errors.New("stock log repository is not available") - } - - for _, egg := range eggs { - if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, - StockableID: egg.Id, - ProductWarehouseID: egg.ProductWarehouseId, - Quantity: float64(egg.Qty), - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) - return err - } - - if strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Increase: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: egg.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -type desiredStock struct { - Usage float64 - Pending float64 -} - -type desiredDepletion struct { - Qty float64 - Pending float64 -} - -func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { - desired := make([]desiredStock, len(stocks)) - for i := range stocks { - if stocks[i].UsageQty != nil { - desired[i].Usage = *stocks[i].UsageQty - } - if stocks[i].PendingQty != nil { - desired[i].Pending = *stocks[i].PendingQty - } - if !enabled { - continue - } - zero := 0.0 - stocks[i].UsageQty = &zero - stocks[i].PendingQty = &zero - } - return desired -} - -func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) { - if !enabled { - return - } - for i := range stocks { - if i >= len(desired) { - break - } - usage := desired[i].Usage - pending := desired[i].Pending - stocks[i].UsageQty = &usage - stocks[i].PendingQty = &pending - } -} - -func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion { - desired := make([]desiredDepletion, len(depletions)) - for i := range depletions { - desired[i].Qty = depletions[i].Qty - desired[i].Pending = depletions[i].PendingQty - if !enabled { - continue - } - depletions[i].Qty = 0 - depletions[i].PendingQty = 0 - } - return desired -} - -func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { - if !enabled { - return - } - for i := range depletions { - if i >= len(desired) { - break - } - depletions[i].Qty = desired[i].Qty - depletions[i].PendingQty = desired[i].Pending - } -} - -func (s *recordingService) syncRecordingStocks( - ctx context.Context, - tx *gorm.DB, - recordingID uint, - existing []entity.RecordingStock, - incoming []validation.Stock, - note string, - actorID uint, -) error { - if s.FifoSvc == nil { - if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { - return err - } - mapped := recordingutil.MapStocks(recordingID, incoming) - return s.Repository.CreateStocks(tx, mapped) - } - - existingByWarehouse := make(map[uint][]entity.RecordingStock) - for _, stock := range existing { - existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) - } - - stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) - for _, item := range incoming { - list := existingByWarehouse[item.ProductWarehouseId] - var stock entity.RecordingStock - if len(list) > 0 { - stock = list[0] - existingByWarehouse[item.ProductWarehouseId] = list[1:] - } else { - zero := 0.0 - stock = entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - UsageQty: &zero, - PendingQty: &zero, - } - if err := tx.Create(&stock).Error; err != nil { - return err - } - } - - desired := item.Qty - stock.UsageQty = &desired - zero := 0.0 - stock.PendingQty = &zero - stocksToConsume = append(stocksToConsume, stock) - } - - var leftovers []entity.RecordingStock - for _, list := range existingByWarehouse { - leftovers = append(leftovers, list...) - } - if len(leftovers) > 0 { - if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { - return err - } - ids := make([]uint, 0, len(leftovers)) - for _, stock := range leftovers { - if stock.Id != 0 { - ids = append(ids, stock.Id) - } - } - if len(ids) > 0 { - if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { - return err - } - } - } - - if len(stocksToConsume) == 0 { - return nil - } - return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) -} - type eggTotals struct { Qty int Weight float64 @@ -1999,16 +1335,17 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e var standard productionStandardValues var standardFcr *float64 - if category == string(utils.ProjectFlockCategoryLaying) { - detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - if detail != nil { - standard.HenDay = detail.TargetHenDayProduction - standard.HenHouse = detail.TargetHenHouseProduction - standard.EggWeight = detail.TargetEggWeight - standard.EggMass = detail.TargetEggMass + detail, err := standardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if detail != nil { + standard.HenDay = detail.TargetHenDayProduction + standard.HenHouse = detail.TargetHenHouseProduction + standard.EggWeight = detail.TargetEggWeight + standard.EggMass = detail.TargetEggMass + if detail.StandardFCR != nil { + standardFcr = detail.StandardFCR } } @@ -2019,21 +1356,6 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e if growthDetail != nil { standard.FeedIntake = growthDetail.FeedIntake standard.MaxDepletion = growthDetail.MaxDepletion - if category == string(utils.ProjectFlockCategoryLaying) && growthDetail.TargetMeanBw != nil && item.ProjectFlockKandang.ProjectFlock.FcrId > 0 { - targetWeight := *growthDetail.TargetMeanBw - if targetWeight > 10 { - targetWeight = targetWeight / 1000 - } - if targetWeight > 0 { - fcrStd, ok, err := s.Repository.GetFcrStandardNumber(db, item.ProjectFlockKandang.ProjectFlock.FcrId, targetWeight) - if err != nil { - return err - } - if ok { - standardFcr = &fcrStd - } - } - } } item.StandardHenDay = standard.HenDay diff --git a/internal/modules/production/recordings/services/recording_fifo.service.go b/internal/modules/production/recordings/services/recording_fifo.service.go new file mode 100644 index 00000000..375b75ce --- /dev/null +++ b/internal/modules/production/recordings/services/recording_fifo.service.go @@ -0,0 +1,703 @@ +package service + +import ( + "context" + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type RecordingFIFOIntegrationService interface { + ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error + ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error +} + +var recordingStockUsableKey = fifo.UsableKeyRecordingStock +var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion + +func NewRecordingFIFOIntegrationService( + repo repository.RecordingRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + fifoSvc commonSvc.FifoService, + stockLogRepo rStockLogs.StockLogRepository, +) RecordingFIFOIntegrationService { + return &recordingService{ + Log: utils.Log, + Repository: repo, + ProductWarehouseRepo: productWarehouseRepo, + FifoSvc: fifoSvc, + StockLogRepo: stockLogRepo, + } +} + +func (s *recordingService) consumeRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + var desired float64 + if stock.UsageQty != nil { + desired = *stock.UsageQty + } + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + desiredTotal := desired + pending + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + ProductWarehouseID: stock.ProductWarehouseId, + Quantity: desiredTotal, + AllowPending: true, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return err + } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) consumeRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + desired := depletion.Qty + depletion.PendingQty + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + ProductWarehouseID: sourceWarehouseID, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { + return err + } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if depletion.ProductWarehouseId == sourceWarehouseID { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Increase: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) ConsumeRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID) +} + +func (s *recordingService) releaseRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + if len(stocks) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingStockUsableKey, + UsableID: stock.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) + return err + } + + if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { + return err + } + + if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Increase: *stock.UsageQty, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) releaseRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { + return err + } + + logIncrease := depletion.Qty + if depletion.PendingQty > 0 { + logIncrease += depletion.PendingQty + } + if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Increase: logIncrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if depletion.ProductWarehouseId == sourceWarehouseID { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Decrease: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock -= log.Decrease + } else { + log.Stock -= log.Decrease + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) ReleaseRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID) +} + +func (s *recordingService) logRecordingEggUsage( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.StockLogRepo == nil { + return nil + } + if strings.TrimSpace(note) == "" || actorID == 0 { + return nil + } + + logs := make([]*entity.StockLog, 0, len(eggs)) + for _, egg := range eggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + latestStockLog := &entity.StockLog{} + if len(stockLogs) > 0 { + latestStockLog = stockLogs[0] + } else { + latestStockLog.Stock = 0 + } + logs = append(logs, &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + Stock: latestStockLog.Stock - float64(egg.Qty), + }) + } + if len(logs) == 0 { + return nil + } + + return s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil) +} + +func (s *recordingService) logRecordingEggRollback( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.StockLogRepo == nil { + return nil + } + if strings.TrimSpace(note) == "" || actorID == 0 { + return nil + } + + for _, egg := range eggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) replenishRecordingEggs( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.FifoSvc == nil { + return nil + } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + + for _, egg := range eggs { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyRecordingEgg, + StockableID: egg.Id, + ProductWarehouseID: egg.ProductWarehouseId, + Quantity: float64(egg.Qty), + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) + return err + } + + if strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Increase: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + log.Stock = latestStockLog.Stock + log.Stock += log.Increase + } else { + log.Stock += log.Increase + } + + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + } + + return nil +} + +type desiredStock struct { + Usage float64 + Pending float64 +} + +type desiredDepletion struct { + Qty float64 + Pending float64 +} + +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock { + desired := make([]desiredStock, len(stocks)) + for i := range stocks { + if stocks[i].UsageQty != nil { + desired[i].Usage = *stocks[i].UsageQty + } + if stocks[i].PendingQty != nil { + desired[i].Pending = *stocks[i].PendingQty + } + if !enabled { + continue + } + zero := 0.0 + stocks[i].UsageQty = &zero + stocks[i].PendingQty = &zero + } + return desired +} + +func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desiredStock, enabled bool) { + if !enabled { + return + } + for i := range stocks { + if i >= len(desired) { + break + } + usage := desired[i].Usage + pending := desired[i].Pending + stocks[i].UsageQty = &usage + stocks[i].PendingQty = &pending + } +} + +func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion { + desired := make([]desiredDepletion, len(depletions)) + for i := range depletions { + desired[i].Qty = depletions[i].Qty + desired[i].Pending = depletions[i].PendingQty + if !enabled { + continue + } + depletions[i].Qty = 0 + depletions[i].PendingQty = 0 + } + return desired +} + +func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) { + if !enabled { + return + } + for i := range depletions { + if i >= len(desired) { + break + } + depletions[i].Qty = desired[i].Qty + depletions[i].PendingQty = desired[i].Pending + } +} + +func (s *recordingService) syncRecordingStocks( + ctx context.Context, + tx *gorm.DB, + recordingID uint, + existing []entity.RecordingStock, + incoming []validation.Stock, + note string, + actorID uint, +) error { + if s.FifoSvc == nil { + if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { + return err + } + mapped := recordingutil.MapStocks(recordingID, incoming) + return s.Repository.CreateStocks(tx, mapped) + } + + existingByWarehouse := make(map[uint][]entity.RecordingStock) + for _, stock := range existing { + existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) + } + + stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) + for _, item := range incoming { + list := existingByWarehouse[item.ProductWarehouseId] + var stock entity.RecordingStock + if len(list) > 0 { + stock = list[0] + existingByWarehouse[item.ProductWarehouseId] = list[1:] + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: &zero, + PendingQty: &zero, + } + if err := tx.Create(&stock).Error; err != nil { + return err + } + } + + desired := item.Qty + stock.UsageQty = &desired + zero := 0.0 + stock.PendingQty = &zero + stocksToConsume = append(stocksToConsume, stock) + } + + var leftovers []entity.RecordingStock + for _, list := range existingByWarehouse { + leftovers = append(leftovers, list...) + } + if len(leftovers) > 0 { + if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { + return err + } + ids := make([]uint, 0, len(leftovers)) + for _, stock := range leftovers { + if stock.Id != 0 { + ids = append(ids, stock.Id) + } + } + if len(ids) > 0 { + if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { + return err + } + } + } + + if len(stocksToConsume) == 0 { + return nil + } + return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) +} From f4790e56ea51c72d42c19bfb02bbcca23bdb40af Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 2 Feb 2026 14:25:37 +0700 Subject: [PATCH 07/34] [FEAT/BE] update herautics for closing counting sapronak --- .../modules/closings/repositories/closing.repository.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index d37ae6c1..827d0f9a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1124,8 +1124,8 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka pw.product_id AS product_id, p.name AS product_name, f.name AS flag, - COALESCE(pi.received_date, st.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date, - COALESCE(po.po_number, st.movement_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference, + COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date, + COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference, 0 AS qty_in, COALESCE(SUM(sa.qty), 0) AS qty_out, COALESCE(p.product_price, 0) AS price @@ -1135,6 +1135,8 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN adjustment_stocks ast_in ON ast_in.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). @@ -1145,7 +1147,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Where("sa.status = ?", entity.StockAllocationStatusActive). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). - Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") + Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoing, err := scanAndGroupDetails(outgoingQuery) if err != nil { From 6e0ff557a815ade38f0fcfc71a1d9fe361839683 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 2 Feb 2026 14:59:29 +0700 Subject: [PATCH 08/34] [FEAT/BE]delete adjustment use in doc closing counting sapronak --- internal/modules/closings/repositories/closing.repository.go | 1 + .../dashboards/repositories/dashboard_stats.repository.go | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 827d0f9a..cd5ce2da 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1147,6 +1147,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Where("sa.status = ?", entity.StockAllocationStatusActive). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). + Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price") outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p") outgoing, err := scanAndGroupDetails(outgoingQuery) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 6645bd9f..3c04f9a0 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -581,13 +581,12 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context %s AS week, COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, - COALESCE(SUM(re.qty), 0) AS total_qty`, - weekExpr, + COALESCE(SUM(re.qty), 0) AS total_qty`, weekExpr), utils.FlagTelurUtuh, utils.FlagTelurPutih, utils.FlagTelurRetak, utils.FlagTelurPecah, - )). + ). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). From 96627e964f33d5efd4d4d32e4394bce2d6900981 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 2 Feb 2026 15:23:15 +0700 Subject: [PATCH 09/34] [FEAT/BE]Purchase rejected payload --- internal/modules/purchases/validations/purchase.validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 564cc96f..6f5d3013 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -51,7 +51,7 @@ type ReceivePurchaseItemRequest struct { type ReceivePurchaseRequest struct { Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"` - Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"min=1,dive"` + Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"omitempty,dive"` TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"` Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"` } From ce108da847b163b7f6e6b2d29395c47146cc8914 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 2 Feb 2026 15:36:06 +0700 Subject: [PATCH 10/34] FEAT[BE] :enhance production data calculations by adding TotalBirdSold and refining profit/loss metrics --- .../services/closingKeuangan.service.go | 85 +++++++++++++------ 1 file changed, 59 insertions(+), 26 deletions(-) diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index ca76c67e..804ca023 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -41,6 +41,7 @@ type ProductionData struct { TotalWeightProduced float64 TotalEggWeightKg float64 TotalWeightSold float64 + TotalBirdSold float64 TotalSalesAmount float64 } @@ -283,6 +284,7 @@ func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlo continue } data.TotalWeightSold += delivery.TotalWeight + data.TotalBirdSold += delivery.UsageQty data.TotalSalesAmount += delivery.TotalPrice } @@ -383,46 +385,77 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection { - totalPopulationIn := production.TotalPopulationIn totalWeightProduced := production.TotalWeightProduced totalEggWeightKg := production.TotalEggWeightKg totalSalesAmount := production.TotalSalesAmount totalWeightSold := production.TotalWeightSold + totalBirdSold := production.TotalBirdSold + actualPopulation := production.TotalPopulationIn - production.TotalDepletion - weightForSales := totalWeightSold - weightForCalculation := totalWeightProduced - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - weightForSales = totalWeightSold - weightForCalculation = totalEggWeightKg - } + isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying) - calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { - if totalPopulationIn > 0 { - rpPerBird = amount / totalPopulationIn - } - if weightForSales > 0 { - rpPerKg = amount / weightForSales + // Fungsi untuk sales: LAYING = populasi aktual, GROWING = ekor terjual + calculateSalesMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if isLaying { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } + } else { + if totalBirdSold > 0 { + rpPerBird = amount / totalBirdSold + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } } return } - actualPopulation := production.TotalPopulationIn - production.TotalDepletion - - calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + // Fungsi untuk cost: per ekor = populasi aktual, per kg = LAYING telur produksi / GROWING ayam produksi + calculateCostMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { if actualPopulation > 0 { rpPerBird = amount / actualPopulation } - if weightForCalculation > 0 { - rpPerKg = amount / weightForCalculation + if isLaying { + if totalEggWeightKg > 0 { + rpPerKg = amount / totalEggWeightKg + } + } else { + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced + } + } + return + } + + // Fungsi untuk overhead/ekspedisi: LAYING = populasi aktual, GROWING = ekor terjual + calculateOverheadMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if isLaying { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } + } else { + if totalBirdSold > 0 { + rpPerBird = amount / totalBirdSold + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } } return } plItems := []dto.ProfitLossItem{} - salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount) + salesRpPerBird, salesRpPerKg := calculateSalesMetrics(totalSalesAmount) salesLabel := "Penjualan Ayam" - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + if isLaying { salesLabel = "Penjualan Telur" } plItems = append(plItems, dto.ToProfitLossItem( @@ -435,23 +468,23 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj )) totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost - _, sapronakRpPerKg := calculateMetrics(totalSapronakAmount) sapronakRpPerBird := 0.0 + sapronakRpPerKg := 0.0 for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} { - rpPerBird, _ := calculateMetrics(amount) + rpPerBird, rpPerKg := calculateCostMetrics(amount) sapronakRpPerBird += rpPerBird + sapronakRpPerKg += rpPerKg } - sapronakLabel := "Pengeluaran Sapronak" plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeSapronak), - sapronakLabel, + "Pengeluaran Sapronak", "purchase", sapronakRpPerBird, sapronakRpPerKg, totalSapronakAmount, )) - overheadRpPerBird, overheadRpPerKg := calculateProfitLossMetrics(costs.RealizationOperational) + overheadRpPerBird, overheadRpPerKg := calculateOverheadMetrics(costs.RealizationOperational) plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeOverhead), "Overhead", @@ -461,7 +494,7 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj costs.RealizationOperational, )) - ekspedisiRpPerBird, ekspedisiRpPerKg := calculateProfitLossMetrics(costs.ExpeditionCost) + ekspedisiRpPerBird, ekspedisiRpPerKg := calculateOverheadMetrics(costs.ExpeditionCost) plItems = append(plItems, dto.ToProfitLossItem( string(dto.PLCodeEkspedisi), "Ekspedisi", From 1c1f2f03aaf12b4b300f9bf6acafc8a7b3c9eaef Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 2 Feb 2026 16:46:33 +0700 Subject: [PATCH 11/34] FIX[BE] :remove unused product warehouse repository import and streamline stock consumption logic in consumeDeliveryStock method --- .../services/deliveryorder.service.go | 65 +++---------------- 1 file changed, 9 insertions(+), 56 deletions(-) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 51e37465..a5eaf856 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -10,7 +10,6 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" @@ -502,69 +501,24 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor Tx: tx, }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) + } + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - totalConsumed := 0.0 - var fifoConsumed float64 - var directConsumed float64 - - if result != nil && result.UsageQuantity > 0 { - fifoConsumed = result.UsageQuantity - totalConsumed = result.UsageQuantity - } - - if err != nil || (totalConsumed < requestedQty) { - remainder := requestedQty - totalConsumed - - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) - if err2 != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") - } - - if pw == nil || pw.Quantity < remainder { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. FIFO: %.2f, Direct Available: %.2f, Total Needed: %.2f", func() float64 { - if pw != nil { - return pw.Quantity - } else { - return 0 - } - }(), remainder, requestedQty)) - } - - if err := pwRepo.AdjustQuantities(ctx, map[uint]float64{ - marketingProduct.ProductWarehouseId: -remainder, - }, func(db *gorm.DB) *gorm.DB { - return tx - }); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to adjust product warehouse quantity") - } - - directConsumed = remainder - totalConsumed += remainder - } - - if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, totalConsumed, 0); err != nil { + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, 0); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - if actorID > 0 && totalConsumed > 0 { - notes := "" - if fifoConsumed > 0 && directConsumed > 0 { - notes = fmt.Sprintf("Partial FIFO (%.2f) + Direct (%.2f)", fifoConsumed, directConsumed) - } else if fifoConsumed > 0 { - notes = fmt.Sprintf("FIFO stock only (%.2f)", fifoConsumed) - } else if directConsumed > 0 { - notes = fmt.Sprintf("Direct stock only (%.2f)", directConsumed) - } - + if actorID > 0 && result.UsageQuantity > 0 { decreaseLog := &entity.StockLog{ - Decrease: totalConsumed, + Decrease: result.UsageQuantity, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, ProductWarehouseId: marketingProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: notes, + Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) if err != nil { @@ -572,8 +526,7 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] - decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Stock -= decreaseLog.Decrease + decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Decrease } else { decreaseLog.Stock -= decreaseLog.Decrease } From e406b20ca7e41e70f1fceb8019dfcfeaa408441d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 2 Feb 2026 21:11:27 +0700 Subject: [PATCH 12/34] FEAT[BE] :Fixing fifo stock when marketing deleted --- internal/modules/marketing/module.go | 2 +- .../services/deliveryorder.service.go | 13 +-- .../marketing/services/salesorder.service.go | 107 +++++++++++++----- 3 files changed, 83 insertions(+), 39 deletions(-) diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 2f8ea4fb..2dde163f 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -64,7 +64,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoService, warehouseRepo, projectFlockKandangRepo, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index a5eaf856..1d0a9481 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -563,6 +563,10 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor return err } + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err + } + if actorID > 0 && currentUsage > 0 { increaseLog := &entity.StockLog{ Increase: currentUsage, @@ -570,7 +574,7 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor LoggableId: deliveryProduct.Id, ProductWarehouseId: marketingProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: "", + Notes: fmt.Sprintf("Release delivery stock (%.2f)", currentUsage), } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) if err != nil { @@ -578,8 +582,7 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor } if len(stockLogs) > 0 { latestStockLog := stockLogs[0] - increaseLog.Stock = latestStockLog.Stock - increaseLog.Stock += increaseLog.Increase + increaseLog.Stock = latestStockLog.Stock + increaseLog.Increase } else { increaseLog.Stock += increaseLog.Increase } @@ -587,9 +590,5 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil) } - if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { - return err - } - return nil } diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 9d950307..df75fe82 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -19,6 +19,7 @@ import ( userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -41,11 +42,12 @@ type salesOrdersService struct { ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository UserRepo userRepo.UserRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo warehouseRepo.WarehouseRepository, +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ Log: utils.Log, @@ -55,6 +57,7 @@ func NewSalesOrdersService(marketingRepo repository.MarketingRepository, custome ProductWarehouseRepo: productWarehouseRepo, UserRepo: userRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, } @@ -230,14 +233,14 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if len(req.MarketingProducts) > 0 { - for _, item := range req.MarketingProducts { - if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { - return nil, err - } - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, - ); err != nil { - return nil, err + for _, item := range req.MarketingProducts { + if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { + return nil, err + } + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "ProductWarehouse", ID: &item.ProductWarehouseId, Exists: s.ProductWarehouseRepo.IdExists}, + ); err != nil { + return nil, err } } } @@ -333,6 +336,32 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u totalPrice = totalWeight * rp.UnitPrice } + deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product") + } + + if err == nil && deliveryProduct.Id != 0 { + oldQty := old.Qty + newQty := rp.Qty + qtyDiff := newQty - oldQty + + if qtyDiff < 0 { + return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.") + } else if qtyDiff > 0 { + _, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: rp.ProductWarehouseId, + Quantity: qtyDiff, + Tx: dbTransaction, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Insufficient stock for additional quantity: %v", err)) + } + } + } + updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, "qty": rp.Qty, @@ -345,25 +374,20 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") } - if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - - mdp := &entity.MarketingDeliveryProduct{ - MarketingProductId: old.Id, - UnitPrice: 0, - TotalWeight: 0, - AvgWeight: 0, - TotalPrice: 0, - DeliveryDate: nil, - VehicleNumber: rp.VehicleNumber, - UsageQty: 0, - PendingQty: 0, - } - if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") - } - } else { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product") + if deliveryProduct.Id == 0 { + mdp := &entity.MarketingDeliveryProduct{ + MarketingProductId: old.Id, + UnitPrice: 0, + TotalWeight: 0, + AvgWeight: 0, + TotalPrice: 0, + DeliveryDate: nil, + VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, + } + if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") } } } else { @@ -380,10 +404,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing delivery product") } - if err == nil { + if err == nil && deliveryProduct.Id != 0 { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) + if deliveryProduct.DeliveryDate != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id)) + } + + if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: dbTransaction, + }); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock: %v", err)) } if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil { @@ -459,6 +491,19 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { marketingRepoTx := repository.NewMarketingRepository(dbTransaction) if len(marketing.Products) > 0 { + deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id) + if err == nil && len(deliveryProducts) > 0 { + for _, dp := range deliveryProducts { + if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: dp.Id, + Tx: dbTransaction, + }); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for delivery product %d: %v", dp.Id, err)) + } + } + } + for _, product := range marketing.Products { if err := marketingDeliveryProductRepoTx.DeleteMany(c.Context(), func(db *gorm.DB) *gorm.DB { return db.Where("marketing_product_id = ?", product.Id).Unscoped() From 58ae03a090af42a3aa9b6013f4c8927d8fe94735 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 2 Feb 2026 21:45:18 +0700 Subject: [PATCH 13/34] FIX[BE] :remove unnecessary quantity calculations in Adjustment method to streamline stock adjustment logic --- .../inventory/adjustments/services/adjustment.service.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index ceefcb1e..862d6991 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -160,7 +160,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } - afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, @@ -183,14 +182,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } if transactionType == string(utils.StockLogTransactionTypeIncrease) { - afterQuantity += req.Quantity newLog.Increase = req.Quantity newLog.Stock += newLog.Increase } else { if productWarehouse.Quantity < req.Quantity { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) } - afterQuantity -= req.Quantity newLog.Decrease = req.Quantity newLog.Stock -= newLog.Decrease } @@ -243,12 +240,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } - productWarehouse.Quantity = afterQuantity - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { - s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) - return err - } - createdAdjustmentStockId = adjustmentStock.Id return nil }) From 9a328ae1e44d914ad0774f8c91278f80fa39dded Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 3 Feb 2026 08:06:52 +0700 Subject: [PATCH 14/34] FEAT[BE] :implement proportional distribution with rounding for stock allocation in transfer laying approval process --- .../services/deliveryorder.service.go | 1 + .../services/transfer_laying.service.go | 85 ++++++++++++------- 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 1d0a9481..6d9392a6 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -520,6 +520,7 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor CreatedBy: actorID, Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), } + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index e6e9a862..15351e56 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strings" "time" @@ -743,7 +744,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - + stockAllocationRepo := commonRepo.NewStockAllocationRepository(dbTransaction) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction) @@ -817,6 +818,27 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } + targetShares := distributeProportionalWithRounding(targets, totalTargetQty, sourceShare) + + for i, target := range targets { + roundedQty := math.Round(targetShares[i]) + if roundedQty <= 0 { + continue + } + mappingAllocation := &entity.StockAllocation{ + StockableType: fifo.UsableKeyTransferToLayingOut.String(), + StockableId: source.Id, + UsableType: fifo.StockableKeyTransferToLayingIn.String(), + UsableId: target.Id, + ProductWarehouseId: *source.ProductWarehouseId, + Qty: roundedQty, + Status: entity.StockAllocationStatusActive, + } + if err := stockAllocationRepo.CreateOne(c.Context(), mappingAllocation, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target") + } + } + stockLogDecrease := &entity.StockLog{ ProductWarehouseId: *source.ProductWarehouseId, CreatedBy: actorID, @@ -937,36 +959,6 @@ func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayi return err } -func (s *transferLayingService) getOrCreateProductWarehouse(ctx context.Context, tx *gorm.DB, productID uint, warehouseID uint, quantity float64, actorID uint, projectFlockKandangId *uint) (*entity.ProductWarehouse, error) { - - productWarehouseRepoTx := rInventory.NewProductWarehouseRepository(tx) - - existing, err := productWarehouseRepoTx.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) - if err == nil && existing != nil { - - if err := productWarehouseRepoTx.PatchOne(ctx, existing.Id, map[string]any{"qty": gorm.Expr("qty + ?", quantity)}, nil); err != nil { - return nil, err - } - return existing, nil - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - - newWarehouse := &entity.ProductWarehouse{ - ProductId: productID, - WarehouseId: warehouseID, - ProjectFlockKandangId: projectFlockKandangId, - Quantity: quantity, - } - - if err := productWarehouseRepoTx.CreateOne(ctx, newWarehouse, nil); err != nil { - return nil, err - } - - return newWarehouse, nil -} - func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) { pf, err := s.ProjectFlockRepo.GetByID(ctx.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { @@ -1060,3 +1052,34 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl return kandangMaxTargetQty, nil } + +func distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 { + if len(targets) == 0 { + return []float64{} + } + + targetShares := make([]float64, len(targets)) + totalRounded := 0.0 + + for i, target := range targets { + targetShares[i] = (target.TotalQty / totalTargetQty) * sourceShare + totalRounded += math.Round(targetShares[i]) + } + + diff := sourceShare - totalRounded + + if diff != 0 { + maxIdx := 0 + maxDecimal := 0.0 + for i, share := range targetShares { + decimal := share - math.Round(share) + if decimal > maxDecimal { + maxDecimal = decimal + maxIdx = i + } + } + targetShares[maxIdx] += diff + } + + return targetShares +} From 82f0db107afae96b1c4dc39321a156315f0a0ee8 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 3 Feb 2026 11:17:11 +0700 Subject: [PATCH 15/34] [FEAT/BE]Fix create avaible qty --- .../recordings/services/recording.service.go | 40 ++++++++++++++----- internal/utils/recording/util.recording.go | 20 ++++++++-- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index c5537b53..29f9cffc 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1073,19 +1073,37 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var remainingChick float64 if totalChick > 0 { totalChickFloat := float64(totalChick) - remainingChick = totalChickFloat - cumDepletionQty - if remainingChick < 0 { - remainingChick = 0 - } - updates["total_chick_qty"] = remainingChick - recording.TotalChickQty = &remainingChick + if s.FifoSvc != nil { + // totalChick already represents available qty (total_qty - total_used_qty). + remainingChick = totalChickFloat + updates["total_chick_qty"] = remainingChick + recording.TotalChickQty = &remainingChick - cumRate := 0.0 - if totalChickFloat > 0 { - cumRate = (cumDepletionQty / totalChickFloat) * 100 + baseChick := initialChickin + if baseChick <= 0 { + baseChick = totalChickFloat + cumDepletionQty + } + cumRate := 0.0 + if baseChick > 0 { + cumRate = (cumDepletionQty / baseChick) * 100 + } + updates["cum_depletion_rate"] = cumRate + recording.CumDepletionRate = &cumRate + } else { + remainingChick = totalChickFloat - cumDepletionQty + if remainingChick < 0 { + remainingChick = 0 + } + updates["total_chick_qty"] = remainingChick + recording.TotalChickQty = &remainingChick + + cumRate := 0.0 + if totalChickFloat > 0 { + cumRate = (cumDepletionQty / totalChickFloat) * 100 + } + updates["cum_depletion_rate"] = cumRate + recording.CumDepletionRate = &cumRate } - updates["cum_depletion_rate"] = cumRate - recording.CumDepletionRate = &cumRate } else { updates["total_chick_qty"] = gorm.Expr("NULL") updates["cum_depletion_rate"] = gorm.Expr("NULL") diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index f40818bf..2b146f5f 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -28,12 +28,26 @@ func MapDepletions(recordingID uint, items []validation.Depletion) []entity.Reco return nil } - result := make([]entity.RecordingDepletion, 0, len(items)) + aggregate := make(map[uint]float64, len(items)) for _, item := range items { + if item.ProductWarehouseId == 0 || item.Qty == 0 { + continue + } + aggregate[item.ProductWarehouseId] += item.Qty + } + if len(aggregate) == 0 { + return nil + } + + result := make([]entity.RecordingDepletion, 0, len(aggregate)) + for warehouseID, qty := range aggregate { + if qty == 0 { + continue + } result = append(result, entity.RecordingDepletion{ RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - Qty: item.Qty, + ProductWarehouseId: warehouseID, + Qty: qty, }) } return result From f75225b81b2d1404c30dd03879f994c1c8be607a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 3 Feb 2026 12:02:45 +0700 Subject: [PATCH 16/34] FEAT[BE] :add production standard detail creation for growing project category with zero target values --- .../services/production-standard.service.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index 2ea95cf3..c2841708 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -152,6 +152,23 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) } + } else if req.ProjectCategory == string(utils.ProjectFlockCategoryGrowing) { + if detailReq.ProductionStandardDetails != nil && detailReq.ProductionStandardDetails.StandardFCR != nil { + var zero float64 = 0 + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: &zero, + TargetHenHouseProduction: &zero, + TargetEggWeight: &zero, + TargetEggMass: &zero, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } } standardGrowthDetail := &entity.StandardGrowthDetail{ @@ -265,6 +282,23 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) } + } else if projectCategory == "GROWING" { + if detailReq.ProductionStandardDetails != nil && detailReq.ProductionStandardDetails.StandardFCR != nil { + var zero float64 = 0 + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: &zero, + TargetHenHouseProduction: &zero, + TargetEggWeight: &zero, + TargetEggMass: &zero, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } } standardGrowthDetail := &entity.StandardGrowthDetail{ From 4eacdd543ae20dc95397c43ba44b81bc747a00dc Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 3 Feb 2026 12:08:11 +0700 Subject: [PATCH 17/34] [FEAT/BE]Fix add new response depletions_rate --- internal/entities/recording.go | 2 + .../recordings/dto/recording.dto.go | 18 ++- .../repositories/recording.repository.go | 18 +++ .../recordings/services/recording.service.go | 104 ++++++++++++++++-- 4 files changed, 134 insertions(+), 8 deletions(-) diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 0cc5dc03..1ca3deb2 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -12,7 +12,9 @@ type Recording struct { RecordDatetime time.Time `gorm:"column:record_datetime;not null"` Day *int `gorm:"column:day"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` + TotalDepletionCumQty *float64 `gorm:"-"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` + DepletionRate *float64 `gorm:"-"` CumIntake *int `gorm:"column:cum_intake"` FcrValue *float64 `gorm:"column:fcr_value"` TotalChickQty *float64 `gorm:"column:total_chick_qty"` diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 0fa14e97..191b9676 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -1,6 +1,7 @@ package dto import ( + "math" "strings" "time" @@ -73,7 +74,9 @@ type RecordingRelationDTO struct { RecordDatetime time.Time `json:"record_datetime"` Day int `json:"day"` TotalDepletionQty float64 `json:"total_depletion_qty"` + TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` CumDepletionRate float64 `json:"cum_depletion_rate"` + DepletionRate float64 `json:"depletion_rate"` CumIntake int `json:"cum_intake"` FcrValue float64 `json:"fcr_value"` HenDay float64 `json:"hen_day"` @@ -230,7 +233,9 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { RecordDatetime: e.RecordDatetime, Day: intValue(e.Day), TotalDepletionQty: floatValue(e.TotalDepletionQty), - CumDepletionRate: floatValue(e.CumDepletionRate), + TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty), + CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), + DepletionRate: roundFloatValue(e.DepletionRate, 2), CumIntake: intValue(e.CumIntake), FcrValue: floatValue(e.FcrValue), HenDay: floatValue(e.HenDay), @@ -426,6 +431,17 @@ func floatValue(value *float64) float64 { return *value } +func roundFloatValue(value *float64, places int) float64 { + if value == nil { + return 0 + } + if places <= 0 { + return math.Round(*value) + } + factor := math.Pow(10, float64(places)) + return math.Round(*value*factor) / factor +} + func intValue(value *int) int { if value == nil { return 0 diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 27c399f4..ce4dc0df 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -39,6 +39,7 @@ type RecordingRepository interface { ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) + GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) GetTotalChickinByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint) (float64, error) @@ -314,6 +315,23 @@ func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingI return result, nil } +func (r *RecordingRepositoryImpl) GetCumulativeDepletionByProjectFlockKandangUntil(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) { + if projectFlockKandangId == 0 || recordTime.IsZero() { + return 0, nil + } + + var total float64 + err := tx. + Table("recording_depletions rd"). + Select("COALESCE(SUM(rd.qty),0)"). + Joins("JOIN recordings r ON r.id = rd.recording_id"). + Where("r.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("r.record_datetime <= ?", recordTime). + Where("r.deleted_at IS NULL"). + Scan(&total).Error + return total, err +} + func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) { if currentDay <= 1 { return nil, nil diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 29f9cffc..28329041 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -128,6 +128,12 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if err := s.attachProductionStandards(c.Context(), recordings); err != nil { return nil, 0, err } + if err := s.attachCumulativeDepletions(c.Context(), recordings); err != nil { + return nil, 0, err + } + if err := s.attachDepletionRates(c.Context(), recordings); err != nil { + return nil, 0, err + } return recordings, total, nil } @@ -152,6 +158,12 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro if err := s.attachProductionStandard(c.Context(), recording); err != nil { return nil, err } + if err := s.attachCumulativeDepletion(c.Context(), recording); err != nil { + return nil, err + } + if err := s.attachDepletionRate(c.Context(), recording); err != nil { + return nil, err + } return recording, nil } @@ -1026,12 +1038,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getPreviousRecording: %w", err) } - var prevCumDepletionQty float64 var prevCumIntake float64 if prevRecording != nil { - if prevRecording.TotalDepletionQty != nil { - prevCumDepletionQty = *prevRecording.TotalDepletionQty - } if prevRecording.CumIntake != nil { prevCumIntake = float64(*prevRecording.CumIntake) } @@ -1063,12 +1071,16 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } currentDepletion := float64(totalDepletionQty) - cumDepletionQty := prevCumDepletionQty + currentDepletion + cumDepletionQty, err := s.Repository.GetCumulativeDepletionByProjectFlockKandangUntil(tx, recording.ProjectFlockKandangId, recording.RecordDatetime) + if err != nil { + return fmt.Errorf("getCumulativeDepletionByProjectFlockKandangUntil: %w", err) + } updates := map[string]any{ - "total_depletion_qty": cumDepletionQty, + "total_depletion_qty": currentDepletion, } - recording.TotalDepletionQty = &cumDepletionQty + recording.TotalDepletionQty = ¤tDepletion + recording.TotalDepletionCumQty = &cumDepletionQty var remainingChick float64 if totalChick > 0 { @@ -1111,6 +1123,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm recording.CumDepletionRate = nil } + depletionRate := computeDepletionRate(prevRecording, currentDepletion, totalChick) + recording.DepletionRate = &depletionRate + var feedIntake float64 if remainingChick > 0 && usageInGrams > 0 { feedIntake = (usageInGrams / remainingChick) * 1000 @@ -1201,6 +1216,81 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return nil } +func computeDepletionRate(prevRecording *entity.Recording, currentDepletion float64, totalChick int64) float64 { + base := 0.0 + if prevRecording != nil && prevRecording.TotalChickQty != nil && *prevRecording.TotalChickQty > 0 { + base = *prevRecording.TotalChickQty + } else if totalChick > 0 { + // totalChick is already remaining after today's depletion; add back current to approximate previous population. + base = float64(totalChick) + currentDepletion + } + if base <= 0 { + return 0 + } + return (currentDepletion / base) * 100 +} + +func (s *recordingService) attachCumulativeDepletion(ctx context.Context, recording *entity.Recording) error { + if recording == nil || recording.ProjectFlockKandangId == 0 || recording.RecordDatetime.IsZero() { + return nil + } + total, err := s.Repository.GetCumulativeDepletionByProjectFlockKandangUntil(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, recording.RecordDatetime) + if err != nil { + return err + } + recording.TotalDepletionCumQty = &total + return nil +} + +func (s *recordingService) attachCumulativeDepletions(ctx context.Context, recordings []entity.Recording) error { + if len(recordings) == 0 { + return nil + } + for i := range recordings { + if err := s.attachCumulativeDepletion(ctx, &recordings[i]); err != nil { + return err + } + } + return nil +} + +func (s *recordingService) attachDepletionRate(ctx context.Context, recording *entity.Recording) error { + if recording == nil { + return nil + } + current := 0.0 + if recording.TotalDepletionQty != nil { + current = *recording.TotalDepletionQty + } + day := 0 + if recording.Day != nil { + day = *recording.Day + } + prev, err := s.Repository.FindPreviousRecording(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId, day) + if err != nil { + return err + } + totalChick, err := s.Repository.GetTotalChick(s.Repository.DB().WithContext(ctx), recording.ProjectFlockKandangId) + if err != nil { + return err + } + rate := computeDepletionRate(prev, current, totalChick) + recording.DepletionRate = &rate + return nil +} + +func (s *recordingService) attachDepletionRates(ctx context.Context, recordings []entity.Recording) error { + if len(recordings) == 0 { + return nil + } + for i := range recordings { + if err := s.attachDepletionRate(ctx, &recordings[i]); err != nil { + return err + } + } + return nil +} + func (s *recordingService) createRecordingApproval( ctx context.Context, db *gorm.DB, From f59cdd821ab9d71d2f2224be5e0e41395db4209c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 3 Feb 2026 13:32:37 +0700 Subject: [PATCH 18/34] FEAT[BE] :add marketing type and conversion fields to marketing entities and services --- ...458_create_transfer_laying_sequence.up.sql | 2 +- ...203054206_update_marketing_tables.down.sql | 8 ++ ...60203054206_update_marketing_tables.up.sql | 9 ++ internal/entities/marketing.go | 1 + internal/entities/marketing_product.go | 19 +-- .../services/deliveryorder.service.go | 40 +++---- .../marketing/services/salesorder.service.go | 108 ++++++++---------- .../validations/salesorder.validation.go | 15 ++- internal/utils/constant.go | 40 +++++++ 9 files changed, 141 insertions(+), 101 deletions(-) create mode 100644 internal/database/migrations/20260203054206_update_marketing_tables.down.sql create mode 100644 internal/database/migrations/20260203054206_update_marketing_tables.up.sql diff --git a/internal/database/migrations/20260129083458_create_transfer_laying_sequence.up.sql b/internal/database/migrations/20260129083458_create_transfer_laying_sequence.up.sql index f5f5bdf7..1a48a512 100644 --- a/internal/database/migrations/20260129083458_create_transfer_laying_sequence.up.sql +++ b/internal/database/migrations/20260129083458_create_transfer_laying_sequence.up.sql @@ -1,5 +1,5 @@ -- Create sequence for transfer laying movement number -CREATE SEQUENCE transfer_laying_seq START +CREATE SEQUENCE IF NOT EXISTS transfer_laying_seq START WITH 1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE; diff --git a/internal/database/migrations/20260203054206_update_marketing_tables.down.sql b/internal/database/migrations/20260203054206_update_marketing_tables.down.sql new file mode 100644 index 00000000..b498f23e --- /dev/null +++ b/internal/database/migrations/20260203054206_update_marketing_tables.down.sql @@ -0,0 +1,8 @@ +-- Remove columns from marketing_products +ALTER TABLE marketing_products +DROP COLUMN IF EXISTS week, +DROP COLUMN IF EXISTS weight_per_convertion, +DROP COLUMN IF EXISTS convertion_unit; + +-- Remove column from marketings +ALTER TABLE marketings DROP COLUMN IF EXISTS marketing_type; \ No newline at end of file diff --git a/internal/database/migrations/20260203054206_update_marketing_tables.up.sql b/internal/database/migrations/20260203054206_update_marketing_tables.up.sql new file mode 100644 index 00000000..72f7c8e7 --- /dev/null +++ b/internal/database/migrations/20260203054206_update_marketing_tables.up.sql @@ -0,0 +1,9 @@ +-- Add marketing_type to marketings table +ALTER TABLE marketings +ADD COLUMN IF NOT EXISTS marketing_type VARCHAR(50); + +-- Add convertion fields to marketing_products table +ALTER TABLE marketing_products +ADD COLUMN IF NOT EXISTS convertion_unit VARCHAR(20), +ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3), +ADD COLUMN IF NOT EXISTS week INTEGER; \ No newline at end of file diff --git a/internal/entities/marketing.go b/internal/entities/marketing.go index c9ff7624..c1ca293b 100644 --- a/internal/entities/marketing.go +++ b/internal/entities/marketing.go @@ -14,6 +14,7 @@ type Marketing struct { SoDate time.Time `gorm:"type:date;not null"` SalesPersonId uint `gorm:"not null"` Notes string `gorm:"type:text"` + MarketingType string `gorm:"type:varchar(50)"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/marketing_product.go b/internal/entities/marketing_product.go index f2294f10..ce13d3d8 100644 --- a/internal/entities/marketing_product.go +++ b/internal/entities/marketing_product.go @@ -1,14 +1,17 @@ package entities type MarketingProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingId uint `gorm:"not null"` - ProductWarehouseId uint `gorm:"not null"` - Qty float64 `gorm:"type:numeric(15,3);not null"` - UnitPrice float64 `gorm:"type:numeric(15,3);not null"` - AvgWeight float64 `gorm:"type:numeric(15,3);not null"` - TotalWeight float64 `gorm:"type:numeric(15,3);not null"` - TotalPrice float64 `gorm:"type:numeric(15,3);not null"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingId uint `gorm:"not null"` + ProductWarehouseId uint `gorm:"not null"` + Qty float64 `gorm:"type:numeric(15,3);not null"` + ConvertionUnit *string `gorm:"type:varchar(20)"` + WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"` + Week *int `gorm:"type:integer"` + UnitPrice float64 `gorm:"type:numeric(15,3);not null"` + AvgWeight float64 `gorm:"type:numeric(15,3);not null"` + TotalWeight float64 `gorm:"type:numeric(15,3);not null"` + TotalPrice float64 `gorm:"type:numeric(15,3);not null"` Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 6d9392a6..80045027 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -237,6 +237,12 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction) + + marketing, err := marketingRepoTx.GetByID(c.Context(), req.MarketingId, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") + } allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), req.MarketingId) if err != nil { @@ -283,23 +289,11 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - isPakanOrOVK := false - if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { - for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { - if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { - isPakanOrOVK = true - break - } - } - } - totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight var totalPrice float64 - if isPakanOrOVK { - + if marketing.MarketingType == string(utils.MarketingTypeTrading) { totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice } else { - totalPrice = totalWeight * requestedProduct.UnitPrice } @@ -374,6 +368,12 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) + marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction) + + marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") + } allMarketingProducts, err := marketingProductRepositoryTx.GetByMarketingID(c.Context(), id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -421,23 +421,11 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - isPakanOrOVK := false - if foundMarketingProduct.ProductWarehouse.Id != 0 && foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { - for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { - if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { - isPakanOrOVK = true - break - } - } - } - totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight var totalPrice float64 - if isPakanOrOVK { - + if marketing.MarketingType == string(utils.MarketingTypeTrading) { totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice } else { - totalPrice = totalWeight * requestedProduct.UnitPrice } diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index df75fe82..a43370d5 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -103,6 +103,10 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e return nil, err } + if !utils.IsValidMarketingType(req.MarketingType) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM PULLET") + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -115,6 +119,9 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } for _, item := range req.MarketingProducts { + if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid convertion_unit. Must be one of: PETI, KG") + } if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { return nil, err } @@ -149,6 +156,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e SoDate: soDate, SalesPersonId: req.SalesPersonId, Notes: req.Notes, + MarketingType: req.MarketingType, CreatedBy: actorID, } if err := marketingRepoTx.CreateOne(c.Context(), marketing, nil); err != nil { @@ -161,10 +169,9 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e if product.ProductWarehouseId != 0 { pwIDs = append(pwIDs, product.ProductWarehouseId) } - if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { + if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, marketing.MarketingType, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") } - } if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { return err @@ -207,6 +214,10 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return nil, err } + if req.MarketingType != "" && !utils.IsValidMarketingType(req.MarketingType) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM PULLET") + } + if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { return nil, err } @@ -234,6 +245,9 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if len(req.MarketingProducts) > 0 { for _, item := range req.MarketingProducts { + if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid convertion_unit. Must be one of: PETI, KG") + } if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { return nil, err } @@ -281,6 +295,9 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if req.Notes != "" { updateBody["notes"] = req.Notes } + if req.MarketingType != "" { + updateBody["marketing_type"] = req.MarketingType + } if len(updateBody) > 0 { if err := marketingRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { @@ -306,31 +323,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u reqByPW[rp.ProductWarehouseId] = rp } + marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") + } + for _, rp := range req.MarketingProducts { if old, ok := oldByPW[rp.ProductWarehouseId]; ok { - // Get product untuk cek flag PAKAN atau OVK - productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { - return db.Preload("Product.Flags") - }) - if err != nil { - return err - } - - // Cek apakah product punya flag PAKAN atau OVK - isPakanOrOVK := false - if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 { - for _, flag := range productWarehouse.Product.Flags { - if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { - isPakanOrOVK = true - break - } - } - } - totalWeight := rp.Qty * rp.AvgWeight var totalPrice float64 - if isPakanOrOVK { + if marketing.MarketingType == string(utils.MarketingTypeTrading) { totalPrice = rp.Qty * rp.UnitPrice } else { totalPrice = totalWeight * rp.UnitPrice @@ -340,7 +343,6 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check delivery product") } - if err == nil && deliveryProduct.Id != 0 { oldQty := old.Qty newQty := rp.Qty @@ -363,12 +365,15 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } updateBody := map[string]any{ - "product_warehouse_id": rp.ProductWarehouseId, - "qty": rp.Qty, - "unit_price": rp.UnitPrice, - "avg_weight": rp.AvgWeight, - "total_weight": totalWeight, - "total_price": totalPrice, + "product_warehouse_id": rp.ProductWarehouseId, + "qty": rp.Qty, + "unit_price": rp.UnitPrice, + "avg_weight": rp.AvgWeight, + "total_weight": totalWeight, + "total_price": totalPrice, + "convertion_unit": rp.ConvertionUnit, + "weight_per_convertion": rp.WeightPerConvertion, + "week": rp.Week, } if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") @@ -391,7 +396,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } } else { - if err := s.createMarketingProductWithDelivery(c.Context(), id, rp, marketingProductRepoTx, invDeliveryRepoTx); err != nil { + if err := s.createMarketingProductWithDelivery(c.Context(), id, marketing.MarketingType, rp, marketingProductRepoTx, invDeliveryRepoTx); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") } } @@ -399,7 +404,6 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u for _, old := range oldProducts { if _, ok := reqByPW[old.ProductWarehouseId]; !ok { - deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing delivery product") @@ -682,45 +686,27 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e return updated, nil } -func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { - - // Get product untuk cek flag PAKAN atau OVK - productWarehouse, err := s.ProductWarehouseRepo.GetByID(ctx, rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { - return db.Preload("Product.Flags") - }) - if err != nil { - return err - } - - // Cek apakah product punya flag PAKAN atau OVK - isPakanOrOVK := false - if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 { - for _, flag := range productWarehouse.Product.Flags { - if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { - isPakanOrOVK = true - break - } - } - } +func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { totalWeight := rp.Qty * rp.AvgWeight var totalPrice float64 - if isPakanOrOVK { - // PAKAN atau OVK: qty × unit_price + if marketingType == string(utils.MarketingTypeTrading) { totalPrice = rp.Qty * rp.UnitPrice } else { - // Produk lain: total_weight × unit_price totalPrice = totalWeight * rp.UnitPrice } marketingProduct := &entity.MarketingProduct{ - MarketingId: marketingId, - ProductWarehouseId: rp.ProductWarehouseId, - Qty: rp.Qty, - UnitPrice: rp.UnitPrice, - AvgWeight: rp.AvgWeight, - TotalWeight: totalWeight, - TotalPrice: totalPrice, + MarketingId: marketingId, + ProductWarehouseId: rp.ProductWarehouseId, + Qty: rp.Qty, + UnitPrice: rp.UnitPrice, + AvgWeight: rp.AvgWeight, + TotalWeight: totalWeight, + TotalPrice: totalPrice, + ConvertionUnit: rp.ConvertionUnit, + WeightPerConvertion: rp.WeightPerConvertion, + Week: rp.Week, } if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil { return err diff --git a/internal/modules/marketing/validations/salesorder.validation.go b/internal/modules/marketing/validations/salesorder.validation.go index b69da394..9a3cee29 100644 --- a/internal/modules/marketing/validations/salesorder.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -5,15 +5,19 @@ type Create struct { SalesPersonId uint `json:"sales_person_id" validate:"required,gt=0"` Date string `json:"date" validate:"required,datetime=2006-01-02"` Notes string `json:"notes" validate:"omitempty,max=500"` + MarketingType string `json:"marketing_type" validate:"required,min=1,max=50"` MarketingProducts []CreateMarketingProduct `json:"marketing_products" validate:"required,min=1,dive"` } type CreateMarketingProduct struct { - VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` - UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` - Qty float64 `json:"qty" validate:"required,gt=0"` - AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` + VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` + ConvertionUnit *string `json:"convertion_unit" validate:"omitempty,min=1,max=20"` + WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"` + Week *int `json:"week" validate:"omitempty,gt=0"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` + UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` + Qty float64 `json:"qty" validate:"required,gt=0"` + AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` } type Update struct { @@ -21,6 +25,7 @@ type Update struct { SalesPersonId uint `json:"sales_person_id" validate:"omitempty,gt=0"` Date string `json:"date" validate:"omitempty,datetime=2006-01-02"` Notes string `json:"notes" validate:"omitempty,max=500"` + MarketingType string `json:"marketing_type" validate:"omitempty,min=1,max=50"` MarketingProducts []CreateMarketingProduct `json:"marketing_products" validate:"omitempty,min=1,dive"` } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 9abd6a30..cb8a0ba2 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -212,6 +212,30 @@ const ( KandangStatusActive KandangStatus = "ACTIVE" ) +// ------------------------------------------------------------------- +// Marketing Type +// ------------------------------------------------------------------- + +type MarketingType string + +const ( + MarketingTypeAyam MarketingType = "AYAM" + MarketingTypeTelur MarketingType = "TELUR" + MarketingTypeTrading MarketingType = "TRADING" + MarketingTypeAyamPullet MarketingType = "AYAM PULLET" +) + +// ------------------------------------------------------------------- +// Convertion Unit +// ------------------------------------------------------------------- + +type ConvertionUnit string + +const ( + ConvertionUnitPeti ConvertionUnit = "PETI" + ConvertionUnitKG ConvertionUnit = "KG" +) + // ------------------------------------------------------------------- // ProjectFlockCategory // ------------------------------------------------------------------- @@ -609,6 +633,22 @@ func IsValidPaymentParty(v string) bool { return false } +func IsValidMarketingType(v string) bool { + switch MarketingType(v) { + case MarketingTypeAyam, MarketingTypeTelur, MarketingTypeTrading, MarketingTypeAyamPullet: + return true + } + return false +} + +func IsValidConvertionUnit(v string) bool { + switch ConvertionUnit(v) { + case ConvertionUnitPeti, ConvertionUnitKG: + return true + } + return false +} + // example use // Recording helper From b862fc41133130597ba716ca0f1dc06b71cb42bb Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 3 Feb 2026 17:01:50 +0700 Subject: [PATCH 19/34] [FEAT/BE]Fix remove fcr master data and changes to standart production --- ...63959_remove_fcr_id_project_flock.down.sql | 47 +++++++++++++++++++ ...3063959_remove_fcr_id_project_flock.up.sql | 26 ++++++++++ internal/entities/projectflock.go | 2 - .../repositories/closing.repository.go | 17 ------- .../closings/services/closing.service.go | 36 ++------------ .../dashboard_stats.repository.go | 24 ---------- .../production/chickins/dto/chickin.dto.go | 31 +++++++----- .../chickins/services/chickin.service.go | 2 +- .../dto/project_flock_kandang.dto.go | 5 +- .../project_flocks/dto/projectflock.dto.go | 27 +++++++---- .../dto/projectflock_kandang.dto.go | 8 +--- .../repositories/projectflock.repository.go | 10 +--- .../projectflock_kandang.repository.go | 8 ++-- .../services/projectflock.service.go | 2 - .../validations/projectflock.validation.go | 1 - .../recordings/dto/recording.dto.go | 6 +-- .../repositories/recording.repository.go | 31 +----------- .../repports/services/repport.service.go | 31 ++++++++++-- 18 files changed, 155 insertions(+), 159 deletions(-) create mode 100644 internal/database/migrations/20260203063959_remove_fcr_id_project_flock.down.sql create mode 100644 internal/database/migrations/20260203063959_remove_fcr_id_project_flock.up.sql diff --git a/internal/database/migrations/20260203063959_remove_fcr_id_project_flock.down.sql b/internal/database/migrations/20260203063959_remove_fcr_id_project_flock.down.sql new file mode 100644 index 00000000..42991241 --- /dev/null +++ b/internal/database/migrations/20260203063959_remove_fcr_id_project_flock.down.sql @@ -0,0 +1,47 @@ +BEGIN; + +DO $$ +DECLARE + fallback_fcr_id BIGINT; +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flocks' + AND column_name = 'fcr_id' + ) THEN + ALTER TABLE project_flocks + ADD COLUMN fcr_id BIGINT; + END IF; + + SELECT id INTO fallback_fcr_id + FROM fcrs + ORDER BY id ASC + LIMIT 1; + + IF fallback_fcr_id IS NOT NULL THEN + UPDATE project_flocks + SET fcr_id = fallback_fcr_id + WHERE fcr_id IS NULL; + + ALTER TABLE project_flocks + ALTER COLUMN fcr_id SET NOT NULL; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'project_flocks_fcr_id_fkey' + ) THEN + ALTER TABLE project_flocks + DROP CONSTRAINT project_flocks_fcr_id_fkey; + END IF; + + ALTER TABLE project_flocks + ADD CONSTRAINT project_flocks_fcr_id_fkey + FOREIGN KEY (fcr_id) REFERENCES fcrs(id) + ON DELETE RESTRICT ON UPDATE CASCADE; +END $$; + +COMMIT; diff --git a/internal/database/migrations/20260203063959_remove_fcr_id_project_flock.up.sql b/internal/database/migrations/20260203063959_remove_fcr_id_project_flock.up.sql new file mode 100644 index 00000000..e34e7d92 --- /dev/null +++ b/internal/database/migrations/20260203063959_remove_fcr_id_project_flock.up.sql @@ -0,0 +1,26 @@ +BEGIN; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'project_flocks' + AND column_name = 'fcr_id' + ) THEN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'project_flocks_fcr_id_fkey' + ) THEN + ALTER TABLE project_flocks + DROP CONSTRAINT project_flocks_fcr_id_fkey; + END IF; + + ALTER TABLE project_flocks + DROP COLUMN fcr_id; + END IF; +END $$; + +COMMIT; diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index 7243c9c4..80d7f886 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -11,7 +11,6 @@ type ProjectFlock struct { FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"` AreaId uint `gorm:"not null"` Category string `gorm:"type:varchar(20);not null"` - FcrId uint `gorm:"not null"` ProductionStandardId uint `gorm:"column:production_standard_id"` LocationId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` @@ -20,7 +19,6 @@ type ProjectFlock struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Area Area `gorm:"foreignKey:AreaId;references:Id"` - Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index cd5ce2da..6ec09858 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -24,7 +24,6 @@ type ClosingRepository interface { SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) - GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) @@ -393,22 +392,6 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla return agg.TotalQty, nil } -func (r *ClosingRepositoryImpl) GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) { - if fcrID == 0 { - return []entity.FcrStandard{}, nil - } - - var standards []entity.FcrStandard - if err := r.DB().WithContext(ctx). - Where("fcr_id = ?", fcrID). - Order("weight ASC"). - Find(&standards).Error; err != nil { - return nil, err - } - - return standards, nil -} - func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) { db := r.DB().WithContext(ctx) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 923a2b1c..71bfcdec 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -836,14 +836,6 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint finalPopulation := population - claimCulling - var standards []entity.FcrStandard - if project.FcrId > 0 { - standards, err = s.Repository.GetFcrStandardsByFcrID(c.Context(), project.FcrId) - if err != nil { - s.Log.Errorf("Failed to fetch FCR standards for project flock %d: %+v", projectFlockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data") - } - } age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID) if err != nil { s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err) @@ -893,7 +885,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint chickenDepletion = 0 } - chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards) +chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) if fcrActFromRecording != nil { chickenPerformance.FcrAct = *fcrActFromRecording } @@ -943,7 +935,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint eggDepletion = 0 } - eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards) + eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age) if fcrActFromRecording != nil { eggPerf.FcrAct = *fcrActFromRecording } @@ -1001,10 +993,10 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint performance.EggMass = eggMass } } - performance.DeffFcr = performance.FcrStd - performance.FcrAct if productionStandardDetail != nil { if productionStandardDetail.StandardFCR != nil { performance.FcrStd = *productionStandardDetail.StandardFCR + performance.DeffFcr = performance.FcrStd - performance.FcrAct } if !isGrowing { if productionStandardDetail.TargetHenDayProduction != nil { @@ -1091,8 +1083,8 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc return week, nil } -func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { - mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) +func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64) dto.ClosingPerformanceDTO { + mortalityStd, fcrStd := 0.0, 0.0 fcrAct := 0.0 if totalWeight > 0 { @@ -1124,21 +1116,3 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul AwgAct: awg, } } - -func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (float64, float64) { - if len(standards) == 0 || averageWeight <= 0 { - return 0, 0 - } - - closest := standards[0] - minDiff := math.Abs(closest.Weight - averageWeight) - for _, std := range standards[1:] { - diff := math.Abs(std.Weight - averageWeight) - if diff < minDiff { - minDiff = diff - closest = std - } - } - - return closest.Mortality, closest.FcrNumber -} diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 3c04f9a0..363e6aa5 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -444,30 +444,6 @@ func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.Dashboa return db } -func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB { - db := r.DB(). - Table("project_flocks AS pf"). - Select("DISTINCT pf.production_standard_id, pf.fcr_id"). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). - Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Where("pf.production_standard_id > 0"). - Where("pf.fcr_id > 0") - - if filters != nil { - if len(filters.FlockIds) > 0 { - db = db.Where("pf.id IN ?", filters.FlockIds) - } - if len(filters.KandangIds) > 0 { - db = db.Where("k.id IN ?", filters.KandangIds) - } - if len(filters.LokasiIds) > 0 { - db = db.Where("k.location_id IN ?", filters.LokasiIds) - } - } - - return db -} - func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) { seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) if err != nil { diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index d53b9491..8a4b0d09 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -5,7 +5,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" areaRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" - fcrRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" flockRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" @@ -13,6 +12,7 @@ import ( warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userRelationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) // === DTO Structs (ordered) === @@ -40,7 +40,7 @@ type ProjectFlockDTO struct { Category string `json:"category"` Flock *flockRelationDTO.FlockRelationDTO `json:"flock"` Area *areaRelationDTO.AreaRelationDTO `json:"area"` - Fcr *fcrRelationDTO.FcrRelationDTO `json:"fcr"` + StandardFcr *float64 `json:"standard_fcr"` Location *locationRelationDTO.LocationRelationDTO `json:"location"` } @@ -97,10 +97,6 @@ func ToAreaDTO(e entity.Area) areaRelationDTO.AreaRelationDTO { return areaRelationDTO.ToAreaRelationDTO(e) } -func ToFcrDTO(e entity.Fcr) fcrRelationDTO.FcrRelationDTO { - return fcrRelationDTO.ToFcrRelationDTO(e) -} - func ToLocationDTO(e entity.Location) locationRelationDTO.LocationRelationDTO { return locationRelationDTO.ToLocationRelationDTO(e) } @@ -121,11 +117,6 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO { mapped := areaRelationDTO.ToAreaRelationDTO(e.Area) area = &mapped } - var fcr *fcrRelationDTO.FcrRelationDTO - if e.Fcr.Id != 0 { - mapped := fcrRelationDTO.ToFcrRelationDTO(e.Fcr) - fcr = &mapped - } var location *locationRelationDTO.LocationRelationDTO if e.Location.Id != 0 { mapped := locationRelationDTO.ToLocationRelationDTO(e.Location) @@ -137,7 +128,7 @@ func ToProjectFlockDTO(pfk entity.ProjectFlockKandang) ProjectFlockDTO { Category: e.Category, Flock: flock, Area: area, - Fcr: fcr, + StandardFcr: resolveProjectFlockStandardFcr(e), Location: location, } } @@ -222,6 +213,22 @@ func ToChickinListDTOs(e []entity.ProjectChickin) []ChickinListDTO { return result } +func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 { + if e.ProductionStandard.Id == 0 || len(e.ProductionStandard.ProductionStandardDetails) == 0 { + return nil + } + week := 1 + if e.Category == string(utils.ProjectFlockCategoryLaying) { + week = 18 + } + for _, detail := range e.ProductionStandard.ProductionStandardDetails { + if detail.Week == week && detail.StandardFCR != nil { + return detail.StandardFCR + } + } + return nil +} + func ToChickinSimpleDTOs(e []entity.ProjectChickin) []ChickinSimpleDTO { result := make([]ChickinSimpleDTO, len(e)) for i, r := range e { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 971ee072..a011c579 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -81,7 +81,7 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.Area"). - Preload("ProjectFlockKandang.ProjectFlock.Fcr"). + Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard.ProductionStandardDetails"). Preload("ProjectFlockKandang.ProjectFlock.Location"). Preload("ProjectFlockKandang.ProjectFlock.Location.Area") diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index c8faf761..8231a551 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -8,7 +8,6 @@ import ( approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" - fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" @@ -31,7 +30,7 @@ type ProjectFlockDTO struct { projectFlockDTO.ProjectFlockRelationDTO Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + StandardFcr *float64 `json:"standard_fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` @@ -86,7 +85,7 @@ func toProjectFlockDTO(pf *projectFlockDTO.ProjectFlockListDTO) *ProjectFlockDTO ProjectFlockRelationDTO: pf.ProjectFlockRelationDTO, Area: pf.Area, Category: pf.Category, - Fcr: pf.Fcr, + StandardFcr: pf.StandardFcr, ProductionStandard: pf.ProductionStandard, Location: pf.Location, CreatedUser: pf.CreatedUser, diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 504d439c..e7240b49 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -6,7 +6,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" - fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" @@ -28,7 +27,7 @@ type ProjectFlockListDTO struct { ProjectFlockRelationDTO Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + StandardFcr *float64 `json:"standard_fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` Kandangs []KandangWithProjectFlockIdDTO `json:"kandangs,omitempty"` @@ -99,12 +98,6 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF areaSummary = &mapped } - var fcrSummary *fcrDTO.FcrRelationDTO - if e.Fcr.Id != 0 { - mapped := fcrDTO.ToFcrRelationDTO(e.Fcr) - fcrSummary = &mapped - } - var productionStandardSummary *productionStandardDTO.ProductionStandardRelationDTO if e.ProductionStandard.Id != 0 { mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProductionStandard) @@ -129,7 +122,7 @@ func ToProjectFlockListDTOWithPeriod(e entity.ProjectFlock, period int) ProjectF Kandangs: kandangSummaries, ProjectBudgets: ToProjectBudgetDTOs(e.Budgets), Category: e.Category, - Fcr: fcrSummary, + StandardFcr: resolveProjectFlockStandardFcr(e), ProductionStandard: productionStandardSummary, Location: locationSummary, CreatedAt: e.CreatedAt, @@ -204,6 +197,22 @@ func createProjectFlockRelationDTO(e entity.ProjectFlock, period int) ProjectFlo } } +func resolveProjectFlockStandardFcr(e entity.ProjectFlock) *float64 { + if e.ProductionStandard.Id == 0 || len(e.ProductionStandard.ProductionStandardDetails) == 0 { + return nil + } + week := 1 + if e.Category == string(utils.ProjectFlockCategoryLaying) { + week = 18 + } + for _, detail := range e.ProductionStandard.ProductionStandardDetails { + if detail.Week == week && detail.StandardFCR != nil { + return detail.StandardFCR + } + } + return nil +} + func ToProjectBudgetDTO(e entity.ProjectBudget) ProjectBudgetDTO { var nonstockRef *nonstockDTO.NonstockRelationDTO if e.Nonstock != nil && e.Nonstock.Id != 0 { diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 39abfe62..5c055a1d 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -5,7 +5,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" - fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" @@ -22,7 +21,7 @@ type ProjectFlockWithPivotDTO struct { ProjectFlockRelationDTO Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + StandardFcr *float64 `json:"standard_fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` ProductionStandardId uint `json:"production_standard_id"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` @@ -67,10 +66,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD mapped := areaDTO.ToAreaRelationDTO(e.ProjectFlock.Area) pfLocal.Area = &mapped } - if e.ProjectFlock.Fcr.Id != 0 { - mapped := fcrDTO.ToFcrRelationDTO(e.ProjectFlock.Fcr) - pfLocal.Fcr = &mapped - } if e.ProjectFlock.ProductionStandard.Id != 0 { mapped := productionStandardDTO.ToProductionStandardRelationDTO(e.ProjectFlock.ProductionStandard) pfLocal.ProductionStandard = &mapped @@ -83,6 +78,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD mapped := userDTO.ToUserRelationDTO(e.ProjectFlock.CreatedUser) pfLocal.CreatedUser = &mapped } + pfLocal.StandardFcr = resolveProjectFlockStandardFcr(e.ProjectFlock) for _, k := range e.ProjectFlock.Kandangs { kb := kandangDTO.ToKandangRelationDTO(k) diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 346f2176..cd7aaba7 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -23,7 +23,6 @@ type ProjectflockRepository interface { GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) IdExists(ctx context.Context, id uint) (bool, error) AreaExists(ctx context.Context, id uint) (bool, error) - FcrExists(ctx context.Context, id uint) (bool, error) ProductionStandardExists(ctx context.Context, id uint) (bool, error) LocationExists(ctx context.Context, id uint) (bool, error) } @@ -67,8 +66,8 @@ func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm return db. Preload("CreatedUser"). Preload("Area"). - Preload("Fcr"). Preload("ProductionStandard"). + Preload("ProductionStandard.ProductionStandardDetails"). Preload("Location"). Preload("Kandangs"). Preload("KandangHistory"). @@ -134,14 +133,12 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s likeQuery := "%" + normalized + "%" return db. Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). - Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). Joins("LEFT JOIN production_standards ON production_standards.id = project_flocks.production_standard_id"). Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). Where(` LOWER(areas.name) LIKE ? OR LOWER(project_flocks.category) LIKE ? - OR LOWER(fcrs.name) LIKE ? OR LOWER(production_standards.name) LIKE ? OR LOWER(locations.name) LIKE ? OR LOWER(locations.address) LIKE ? @@ -172,7 +169,6 @@ func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch s likeQuery, likeQuery, likeQuery, - likeQuery, ) } @@ -184,10 +180,6 @@ func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (b return repository.Exists[entity.Area](ctx, r.DB(), id) } -func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.Fcr](ctx, r.DB(), id) -} - func (r *ProjectflockRepositoryImpl) ProductionStandardExists(ctx context.Context, id uint) (bool, error) { return repository.Exists[entity.ProductionStandard](ctx, r.DB(), id) } diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 9f5bb0e2..002c2c58 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -117,10 +117,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\""). Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails"). Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). @@ -208,10 +208,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context. Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\""). Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails"). Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). @@ -324,10 +324,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails"). Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). @@ -347,10 +347,10 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont if err := r.db.WithContext(ctx). Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). Preload("ProjectFlock.CreatedUser"). + Preload("ProjectFlock.ProductionStandard.ProductionStandardDetails"). Preload("ProjectFlock.Kandangs"). Preload("ProjectFlock.KandangHistory"). Preload("Kandang"). diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 96e4b6b0..224f43bf 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -282,7 +282,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, - commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists}, commonSvc.RelationCheck{Name: "Production Standard", ID: &req.ProductionStandardId, Exists: s.Repository.ProductionStandardExists}, commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, ); err != nil { @@ -334,7 +333,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* createBody := &entity.ProjectFlock{ AreaId: req.AreaId, Category: cat, - FcrId: req.FcrId, ProductionStandardId: req.ProductionStandardId, LocationId: req.LocationId, CreatedBy: actorID, diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 1fb48abe..ca347d47 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -4,7 +4,6 @@ type Create struct { FlockName string `json:"flock_name" validate:"required_strict"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` Category string `json:"category" validate:"required_strict"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` ProductionStandardId uint `json:"production_standard_id" validate:"required_strict,number,gt=0"` LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` KandangIds []uint `json:"kandang_ids" validate:"required,min=1,dive,gt=0"` diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 191b9676..ec2f3657 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -280,10 +280,10 @@ func toRecordingProjectFlockDTO(e entity.Recording) RecordingProjectFlockDTO { } } - if pfk.ProjectFlock.Fcr.Id != 0 || e.StandardFcr != nil { + if pfk.ProjectFlock.ProductionStandard.Id != 0 || e.StandardFcr != nil { result.Fcr = &RecordingFcrDTO{ - Id: pfk.ProjectFlock.Fcr.Id, - Name: pfk.ProjectFlock.Fcr.Name, + Id: pfk.ProjectFlock.ProductionStandard.Id, + Name: pfk.ProjectFlock.ProductionStandard.Name, FcrStd: floatValue(e.StandardFcr), } } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index ce4dc0df..b9867d2b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -46,7 +46,6 @@ type RecordingRepository interface { GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) - GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) @@ -92,7 +91,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Kandang.Location"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). - Preload("ProjectFlockKandang.ProjectFlock.Fcr"). + // Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("Depletions"). Preload("Depletions.ProductWarehouse"). Preload("Depletions.ProductWarehouse.Product"). @@ -448,34 +447,6 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang( Scan(&result).Error return result, err } - -func (r *RecordingRepositoryImpl) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) { - if fcrId == 0 || currentWeightKg <= 0 { - return 0, false, nil - } - - var standard entity.FcrStandard - err := tx. - Where("fcr_id = ? AND weight >= ?", fcrId, currentWeightKg). - Order("weight ASC"). - First(&standard).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - err = tx. - Where("fcr_id = ?", fcrId). - Order("weight DESC"). - First(&standard).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, false, nil - } - } - if err != nil { - return 0, false, err - } - - return standard.FcrNumber, true, nil -} - func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { // Body-weight tracking is removed; keep stub for report compatibility. return 0, 0, nil diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index d45cba62..d417642e 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1352,10 +1352,12 @@ func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Loc poNumber = *purchase.PoNumber } - prDate := purchase.CreatedAt.In(loc) - startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) + startDate := resolveDebtSupplierReceivedDate(purchase, loc) endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) - aging := int(endDate.Sub(startDate).Hours() / 24) + aging := 0 + if !startDate.IsZero() { + aging = int(endDate.Sub(startDate).Hours() / 24) + } totalPrice := 0.0 travelNumber := "-" @@ -1525,8 +1527,10 @@ func isDebtSupplierPaid(totalPrice, paymentTotal float64) bool { } func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int { - prDate := purchase.CreatedAt.In(loc) - startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) + startDate := resolveDebtSupplierReceivedDate(purchase, loc) + if startDate.IsZero() { + return 0 + } stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc) if stopDate.Before(startDate) { return 0 @@ -1534,6 +1538,23 @@ func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc return int(stopDate.Sub(startDate).Hours() / 24) } +func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Location) time.Time { + earliest := time.Time{} + for _, item := range purchase.Items { + if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { + continue + } + received := item.ReceivedDate.In(loc) + if earliest.IsZero() || received.Before(earliest) { + earliest = received + } + } + if earliest.IsZero() { + return time.Time{} + } + return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc) +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { From 7183df6938421d144784197dfbf9f931f8c396d2 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 4 Feb 2026 09:17:16 +0700 Subject: [PATCH 20/34] add query adjustment stock at closing sapronak --- ...260203034048_add_field_adj_number.down.sql | 6 + ...20260203034048_add_field_adj_number.up.sql | 10 ++ internal/entities/adjustment_stock.go | 1 + .../repositories/closing.repository.go | 133 ++++++++++++++++-- .../adjustment_stock.repository.go | 73 ++++++++++ .../services/adjustment.service.go | 5 + internal/utils/constant.go | 7 +- 7 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 internal/database/migrations/20260203034048_add_field_adj_number.down.sql create mode 100644 internal/database/migrations/20260203034048_add_field_adj_number.up.sql diff --git a/internal/database/migrations/20260203034048_add_field_adj_number.down.sql b/internal/database/migrations/20260203034048_add_field_adj_number.down.sql new file mode 100644 index 00000000..48bb2b54 --- /dev/null +++ b/internal/database/migrations/20260203034048_add_field_adj_number.down.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TABLE adjustment_stocks +DROP COLUMN adj_number; + +COMMIT; diff --git a/internal/database/migrations/20260203034048_add_field_adj_number.up.sql b/internal/database/migrations/20260203034048_add_field_adj_number.up.sql new file mode 100644 index 00000000..1517bbea --- /dev/null +++ b/internal/database/migrations/20260203034048_add_field_adj_number.up.sql @@ -0,0 +1,10 @@ +BEGIN; + +ALTER TABLE adjustment_stocks +ADD COLUMN adj_number VARCHAR(255); + +UPDATE adjustment_stocks +SET adj_number = CONCAT('ADJ-', LPAD(id::text, 5, '0')) +WHERE adj_number IS NULL; + +COMMIT; diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index 841e4820..9ccf9246 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -11,6 +11,7 @@ type AdjustmentStock struct { PendingQty float64 `gorm:"column:pending_qty;default:0"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index cd5ce2da..9b7c5bff 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -102,12 +102,12 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak if len(params.WarehouseIDs) == 0 { return []SapronakRow{}, 0, nil } - unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) - args = append(args, params.WarehouseIDs, params.WarehouseIDs) + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs) case validation.SapronakTypeOutgoing: if len(params.WarehouseIDs) > 0 { - unionParts = append(unionParts, sapronakOutgoingTransfersSQL) - args = append(args, params.WarehouseIDs) + unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) } if len(params.ProjectFlockKandangIDs) > 0 { unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) @@ -174,12 +174,12 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S if len(params.WarehouseIDs) == 0 { return []SapronakSummaryRow{}, nil } - unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) - args = append(args, params.WarehouseIDs, params.WarehouseIDs) + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs) case validation.SapronakTypeOutgoing: if len(params.WarehouseIDs) > 0 { - unionParts = append(unionParts, sapronakOutgoingTransfersSQL) - args = append(args, params.WarehouseIDs) + unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) } if len(params.ProjectFlockKandangIDs) > 0 { unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) @@ -456,7 +456,7 @@ SELECT COALESCE(pi.received_date, '1970-01-01') AS sort_date, COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, COALESCE(p.po_number, '') AS reference_number, - 'Purchase' AS transaction_type, + 'Pembelian' AS transaction_type, prod.name AS product_name, COALESCE(( SELECT string_agg( @@ -505,7 +505,7 @@ SELECT st.transfer_date AS sort_date, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, st.movement_number AS reference_number, - 'Internal Transfer In' AS transaction_type, + 'Mutasi' AS transaction_type, prod.name AS product_name, COALESCE(( SELECT string_agg( @@ -549,13 +549,63 @@ JOIN uoms u ON u.id = prod.uom_id WHERE st.to_warehouse_id IN ? ` + sapronakIncomingAdjustmentsSQL = ` +SELECT + CAST(ast.id AS BIGINT) AS id, + ast.created_at AS sort_date, + COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(ast.adj_number, '') AS reference_number, + 'Adjustment stock' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(w.name, '') AS source_warehouse, + '-' AS destination_warehouse, + '' AS destination, + COALESCE(ast.total_qty, 0) AS quantity, + u.id AS unit_id, + u.name AS unit, + '-' AS notes +FROM adjustment_stocks ast +JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id +JOIN warehouses w ON w.id = pw.warehouse_id +JOIN products prod ON prod.id = pw.product_id +JOIN uoms u ON u.id = prod.uom_id +WHERE pw.warehouse_id IN ? + AND COALESCE(ast.total_qty, 0) <> 0 +` + sapronakOutgoingTransfersSQL = ` SELECT CAST(st.id AS BIGINT) AS id, st.transfer_date AS sort_date, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, st.movement_number AS reference_number, - 'Internal Transfer Out' AS transaction_type, + 'Mutasi' AS transaction_type, prod.name AS product_name, COALESCE(( SELECT string_agg( @@ -599,13 +649,70 @@ JOIN uoms u ON u.id = prod.uom_id WHERE st.from_warehouse_id IN ? ` + sapronakOutgoingAdjustmentsSQL = ` +SELECT + CAST(ast.id AS BIGINT) AS id, + ast.created_at AS sort_date, + COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(ast.adj_number, '') AS reference_number, + 'Adjustment stock' AS transaction_type, + prod.name AS product_name, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(w.name, '') AS source_warehouse, + '-' AS destination_warehouse, + '' AS destination, + COALESCE(ast.usage_qty, 0) AS quantity, + u.id AS unit_id, + u.name AS unit, + '-' AS notes +FROM adjustment_stocks ast +JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id +JOIN warehouses w ON w.id = pw.warehouse_id +JOIN products prod ON prod.id = pw.product_id +JOIN uoms u ON u.id = prod.uom_id +WHERE pw.warehouse_id IN ? + AND COALESCE(ast.usage_qty, 0) <> 0 + AND EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_id = pw.product_id + AND f.flagable_type = 'products' + AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK') + ) +` + sapronakOutgoingMarketingsSQL = ` SELECT CAST(mp.id AS BIGINT) AS id, m.so_date AS sort_date, TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text, m.so_number AS reference_number, - 'Trading Sales' AS transaction_type, + 'Penjualan' AS transaction_type, prod.name AS product_name, COALESCE(( SELECT string_agg( @@ -653,7 +760,7 @@ WHERE pw.project_flock_kandang_id IN ? FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = 'products' - AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET') + AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK') ) ` ) diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index f62738a3..9409fd73 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -2,9 +2,13 @@ package repositories import ( "context" + "fmt" + "strconv" + "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type AdjustmentStockRepository interface { @@ -12,6 +16,7 @@ type AdjustmentStockRepository interface { GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) WithTx(tx *gorm.DB) AdjustmentStockRepository DB() *gorm.DB + GenerateSequentialNumber(ctx context.Context, prefix string) (string, error) } type adjustmentStockRepositoryImpl struct { @@ -50,3 +55,71 @@ func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepos func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { return r.db } + +func (r *adjustmentStockRepositoryImpl) GenerateSequentialNumber(ctx context.Context, prefix string) (string, error) { + var values []string + err := r.db.WithContext(ctx). + Model(&entity.AdjustmentStock{}). + Where(fmt.Sprintf("%s ILIKE ?", "adj_number"), prefix+"%"). + Select("adj_number"). + Order(fmt.Sprintf("%s DESC", "adj_number")). + Limit(20). + Clauses(clause.Locking{Strength: "UPDATE"}). + Pluck("adj_number", &values).Error + if err != nil { + return "", err + } + + next := 1 + for _, value := range values { + if number, ok := parseNumericSuffix(value, prefix); ok { + next = number + 1 + break + } + } + + const maxAttempts = 20 + for attempt := 0; attempt < maxAttempts; attempt++ { + candidate := fmt.Sprintf("%s%0*d", prefix, 5, next) + exists, err := r.numberExists(ctx, r.db, candidate) + if err != nil { + return "", err + } + if !exists { + return candidate, nil + } + next++ + } + + return "", fmt.Errorf("unable to generate unique %s", "adj_number") +} + +func (r *adjustmentStockRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, value string) (bool, error) { + var count int64 + if err := db.WithContext(ctx). + Model(&entity.AdjustmentStock{}). + Where(fmt.Sprintf("%s = ?", "adj_number"), value). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func parseNumericSuffix(value, prefix string) (int, bool) { + if !strings.HasPrefix(value, prefix) { + return 0, false + } + suffix := strings.TrimPrefix(value, prefix) + if suffix == "" { + return 0, false + } + trimmed := strings.TrimLeft(suffix, "0") + if trimmed == "" { + trimmed = "0" + } + number, err := strconv.Atoi(trimmed) + if err != nil { + return 0, false + } + return number, true +} diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 862d6991..a763a6c6 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -200,6 +200,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e adjustmentStock := &entity.AdjustmentStock{ ProductWarehouseId: productWarehouse.Id, } + code, err := s.AdjustmentStockRepository.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) + if err != nil { + return err + } + adjustmentStock.AdjNumber = code if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 9abd6a30..3b8ee054 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -329,9 +329,10 @@ const ( PurchaseStepReceiving approvalutils.ApprovalStep = 4 PurchaseStepCompleted approvalutils.ApprovalStep = 5 - PurchasePRNumberPrefix = "PR-LTI-" - PurchasePONumberPrefix = "PO-LTI-" - PurchaseNumberPadding = 4 + PurchasePRNumberPrefix = "PR-LTI-" + PurchasePONumberPrefix = "PO-LTI-" + AdjustmentStockNumberPrefix = "ADJ-" + PurchaseNumberPadding = 4 ) var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{ From 90de167fcd700eacc46ca18780748a8c1c270b9f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 4 Feb 2026 09:59:15 +0700 Subject: [PATCH 21/34] FEAT[BE] :add type filtering and validation to product warehouse services --- .../product_warehouse.controller.go | 1 + .../product_warehouse.repository.go | 7 ++-- .../services/product_warehouse.service.go | 23 ++++++++++- .../product_warehouse.validation.go | 1 + .../services/deliveryorder.service.go | 30 +++++++------- .../marketing/services/salesorder.service.go | 40 +++++++++++-------- .../validations/salesorder.validation.go | 2 +- internal/utils/constant.go | 2 +- 8 files changed, 70 insertions(+), 36 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index 47d85a65..bc6cdaed 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Flags: c.Query("flags", ""), KandangId: uint(c.QueryInt("kandang_id", 0)), TransferContext: c.Query(utils.TransferContextKey, ""), + Type: c.Query("type", ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index a7fe452b..e49fc421 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -168,9 +168,10 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s } return db. - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). - Where("flags.name IN ?", flags) + Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id"). + Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products"). + Where("f_flag.name IN ?", flags). + Distinct() } func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error { diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 5bb3f692..98656de1 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -99,6 +99,12 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) offset := (params.Page - 1) * params.Limit + if params.Type != "" { + if !utils.IsValidMarketingType(params.Type) { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type") + } + } + cleanFlags := utils.ParseFlags(params.Flags) productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { @@ -128,7 +134,22 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } - db = s.Repository.ApplyFlagsFilter(db, cleanFlags) + if params.Type != "" { + switch params.Type { + case string(utils.MarketingTypeAyamPullet): + db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)}) + case string(utils.MarketingTypeAyam): + db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati)}) + case string(utils.MarketingTypeTelur): + db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), string(utils.FlagTelurPutih), string(utils.FlagTelurRetak)}) + case string(utils.MarketingTypeTrading): + db = s.Repository.ApplyFlagsFilter(db, []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher), string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)}) + } + } + + if len(cleanFlags) > 0 { + db = s.Repository.ApplyFlagsFilter(db, cleanFlags) + } return db.Order("product_warehouses.id DESC") }) diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 61a41ad0..7e7da7a6 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -20,4 +20,5 @@ type Query struct { Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` + Type string `query:"type" validate:"omitempty,oneof=AYAM TELUR TRADING AYAM_PULLET"` } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 80045027..268a81eb 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -289,13 +289,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - var totalPrice float64 - if marketing.MarketingType == string(utils.MarketingTypeTrading) { - totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice - } else { - totalPrice = totalWeight * requestedProduct.UnitPrice - } + totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week) deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -421,13 +415,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - var totalPrice float64 - if marketing.MarketingType == string(utils.MarketingTypeTrading) { - totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice - } else { - totalPrice = totalWeight * requestedProduct.UnitPrice - } + totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week) deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -471,6 +459,20 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } +func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) { + if marketingType == string(utils.MarketingTypeTrading) { + totalWeight = 0 + totalPrice = qty * unitPrice + } else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 { + totalWeight = qty * avgWeight + totalPrice = unitPrice * float64(*week) * qty + } else { + totalWeight = qty * avgWeight + totalPrice = totalWeight * unitPrice + } + return totalWeight, totalPrice +} + func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index a43370d5..a64caa9f 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -104,7 +104,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } if !utils.IsValidMarketingType(req.MarketingType) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM PULLET") + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM_PULLET") } actorID, err := m.ActorIDFromContext(c) @@ -119,6 +119,9 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } for _, item := range req.MarketingProducts { + if req.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "avg_weight is required for non-TRADING marketing type") + } if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid convertion_unit. Must be one of: PETI, KG") } @@ -215,7 +218,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if req.MarketingType != "" && !utils.IsValidMarketingType(req.MarketingType) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM PULLET") + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM_PULLET") } if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { @@ -245,6 +248,9 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if len(req.MarketingProducts) > 0 { for _, item := range req.MarketingProducts { + if req.MarketingType != "" && req.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "avg_weight is required for non-TRADING marketing type") + } if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid convertion_unit. Must be one of: PETI, KG") } @@ -331,13 +337,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u for _, rp := range req.MarketingProducts { if old, ok := oldByPW[rp.ProductWarehouseId]; ok { - totalWeight := rp.Qty * rp.AvgWeight - var totalPrice float64 - if marketing.MarketingType == string(utils.MarketingTypeTrading) { - totalPrice = rp.Qty * rp.UnitPrice - } else { - totalPrice = totalWeight * rp.UnitPrice - } + totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week) deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -688,13 +688,7 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, marketingType string, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { - totalWeight := rp.Qty * rp.AvgWeight - var totalPrice float64 - if marketingType == string(utils.MarketingTypeTrading) { - totalPrice = rp.Qty * rp.UnitPrice - } else { - totalPrice = totalWeight * rp.UnitPrice - } + totalWeight, totalPrice := s.calculatePriceByMarketingType(marketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week) marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, @@ -730,3 +724,17 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont return nil } + +func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) { + if marketingType == string(utils.MarketingTypeTrading) { + totalWeight = 0 + totalPrice = qty * unitPrice + } else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 { + totalWeight = qty * avgWeight + totalPrice = unitPrice * float64(*week) * qty + } else { + totalWeight = qty * avgWeight + totalPrice = totalWeight * unitPrice + } + return totalWeight, totalPrice +} diff --git a/internal/modules/marketing/validations/salesorder.validation.go b/internal/modules/marketing/validations/salesorder.validation.go index 9a3cee29..bf38417f 100644 --- a/internal/modules/marketing/validations/salesorder.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -17,7 +17,7 @@ type CreateMarketingProduct struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` Qty float64 `json:"qty" validate:"required,gt=0"` - AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` + AvgWeight float64 `json:"avg_weight" validate:"omitempty,gt=0"` } type Update struct { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index cb8a0ba2..1de04fa3 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -222,7 +222,7 @@ const ( MarketingTypeAyam MarketingType = "AYAM" MarketingTypeTelur MarketingType = "TELUR" MarketingTypeTrading MarketingType = "TRADING" - MarketingTypeAyamPullet MarketingType = "AYAM PULLET" + MarketingTypeAyamPullet MarketingType = "AYAM_PULLET" ) // ------------------------------------------------------------------- From 474c42770b507e514631bd32e606701d34783be5 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 4 Feb 2026 11:46:32 +0700 Subject: [PATCH 22/34] FEAT[BE] :add week calculation and chickin preload to product warehouse services --- .../dto/product_warehouse.dto.go | 73 +++++++++++++++++++ .../services/product_warehouse.service.go | 3 +- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index b8f51c52..b9c95004 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -6,6 +6,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) // === DTO Structs === @@ -22,6 +23,7 @@ type ProductWarehouseListDTO struct { Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + Week int `json:"week"` CreatedUser *UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -109,6 +111,22 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT } dto.ProjectFlockKandang = pfkDTO + + // Calculate week for AYAM_PULLET/AYAM products + productFlags := make([]string, len(e.Product.Flags)) + for i, f := range e.Product.Flags { + productFlags[i] = f.Name + } + + var category string + if e.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = e.ProjectFlockKandang.ProjectFlock.Category + } + + now := time.Now() + _, ageInWeeks := calculateAgeFromChickin(e.ProjectFlockKandang, &now, productFlags, category) + + dto.Week = ageInWeeks } return dto @@ -138,3 +156,58 @@ func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNeste Warehouse: &warehouse, } } + +// Helper function to calculate age from chickin (same logic as closingMarketing.dto.go) +func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, currentDate *time.Time, productFlags []string, category string) (int, int) { + if projectFlockKandang == nil || currentDate == nil || len(projectFlockKandang.Chickins) == 0 { + return 0, 0 + } + + // Return 0 for TRADING, TELUR, and AYAM flags (only AYAM_PULLET should have week) + for _, flag := range productFlags { + if flag == string(utils.FlagOVK) || + flag == string(utils.FlagPakan) || + flag == string(utils.FlagPreStarter) || + flag == string(utils.FlagStarter) || + flag == string(utils.FlagFinisher) || + flag == string(utils.FlagObat) || + flag == string(utils.FlagVitamin) || + flag == string(utils.FlagKimia) || + flag == string(utils.FlagEkspedisi) || + flag == string(utils.FlagTelur) || + flag == string(utils.FlagTelurUtuh) || + flag == string(utils.FlagTelurPecah) || + flag == string(utils.FlagTelurPutih) || + flag == string(utils.FlagTelurRetak) || + flag == string(utils.FlagAyamAfkir) || + flag == string(utils.FlagAyamCulling) || + flag == string(utils.FlagAyamMati) { + return 0, 0 + } + } + + // Find earliest chickin date + earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate + for _, chickin := range projectFlockKandang.Chickins { + if chickin.ChickInDate.Before(earliestChickinDate) { + earliestChickinDate = chickin.ChickInDate + } + } + + diff := currentDate.Sub(earliestChickinDate) + ageInDays := int(diff.Hours() / 24) + + var ageInWeeks int + if ageInDays <= 0 { + ageInWeeks = 0 + } else { + if category == string(utils.ProjectFlockCategoryLaying) { + ageInDays = ageInDays + 119 + ageInWeeks = ((ageInDays - 1) / 7) + 1 + } else { + ageInWeeks = ((ageInDays - 1) / 7) + 1 + } + } + + return ageInDays, ageInWeeks +} diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 98656de1..7132644e 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -46,7 +46,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). Preload("ProjectFlockKandang"). - Preload("ProjectFlockKandang.ProjectFlock") + Preload("ProjectFlockKandang.ProjectFlock"). + Preload("ProjectFlockKandang.Chickins") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { From 357b5709f50c9142c8809eab1e79e337e08eda97 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 4 Feb 2026 12:48:05 +0700 Subject: [PATCH 23/34] FEAT[BE] :add conversion fields and week tracking to marketing product DTOs and update mapping functions --- .../marketing/dto/deliveryorder.dto.go | 61 ++++++++++++------- .../modules/marketing/dto/salesorder.dto.go | 49 +++++++++------ .../services/deliveryorder.service.go | 2 + .../marketing/services/salesorder.service.go | 1 + 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index 4bcbacca..451856c2 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -76,16 +76,20 @@ type DeliveryGroupDTO struct { } type DeliveryMarketingProductDTO struct { - Id uint `json:"id"` - MarketingId uint `json:"marketing_id"` - ProductWarehouseId uint `json:"product_warehouse_id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - AvgWeight float64 `json:"avg_weight"` - TotalWeight float64 `json:"total_weight"` - TotalPrice float64 `json:"total_price"` - ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` - VehicleNumber string `json:"vehicle_number,omitempty"` + Id uint `json:"id"` + MarketingId uint `json:"marketing_id"` + ProductWarehouseId uint `json:"product_warehouse_id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + AvgWeight float64 `json:"avg_weight"` + TotalWeight float64 `json:"total_weight"` + TotalPrice float64 `json:"total_price"` + ConvertionUnit *string `json:"convertion_unit,omitempty"` + WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"` + TotalPeti *float64 `json:"total_peti,omitempty"` + Week *int `json:"week,omitempty"` + ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` + VehicleNumber string `json:"vehicle_number,omitempty"` } func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { @@ -97,24 +101,35 @@ func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { } } -func ToDeliveryMarketingProductDTO(e entity.MarketingProduct) DeliveryMarketingProductDTO { +func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType string) DeliveryMarketingProductDTO { var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO if e.ProductWarehouse.Id != 0 { mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) productWarehouse = &mapped } + // Calculate total_peti only for TELUR marketing type + var totalPeti *float64 + if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 { + calculated := e.TotalWeight / *e.WeightPerConvertion + totalPeti = &calculated + } + return DeliveryMarketingProductDTO{ - Id: e.Id, - MarketingId: e.MarketingId, - ProductWarehouseId: e.ProductWarehouseId, - Qty: e.Qty, - UnitPrice: e.UnitPrice, - AvgWeight: e.AvgWeight, - TotalWeight: e.TotalWeight, - TotalPrice: e.TotalPrice, - ProductWarehouse: productWarehouse, - VehicleNumber: getVehicleNumber(e), + Id: e.Id, + MarketingId: e.MarketingId, + ProductWarehouseId: e.ProductWarehouseId, + Qty: e.Qty, + UnitPrice: e.UnitPrice, + AvgWeight: e.AvgWeight, + TotalWeight: e.TotalWeight, + TotalPrice: e.TotalPrice, + ConvertionUnit: e.ConvertionUnit, + WeightPerConvertion: e.WeightPerConvertion, + TotalPeti: totalPeti, + Week: e.Week, + ProductWarehouse: productWarehouse, + VehicleNumber: getVehicleNumber(e), } } @@ -161,7 +176,7 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M if len(marketing.Products) > 0 { salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) } } @@ -201,7 +216,7 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity if len(marketing.Products) > 0 { salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) } } diff --git a/internal/modules/marketing/dto/salesorder.dto.go b/internal/modules/marketing/dto/salesorder.dto.go index 86bd5f84..866fe268 100644 --- a/internal/modules/marketing/dto/salesorder.dto.go +++ b/internal/modules/marketing/dto/salesorder.dto.go @@ -10,13 +10,17 @@ import ( // === DTO Structs === type MarketingProductDTO struct { - Id uint `json:"id"` - Qty float64 `json:"qty"` - UnitPrice float64 `json:"unit_price"` - AvgWeight float64 `json:"avg_weight"` - TotalWeight float64 `json:"total_weight"` - TotalPrice float64 `json:"total_price"` - ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` + Id uint `json:"id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + AvgWeight float64 `json:"avg_weight"` + TotalWeight float64 `json:"total_weight"` + TotalPrice float64 `json:"total_price"` + ConvertionUnit *string `json:"convertion_unit,omitempty"` + WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"` + TotalPeti *float64 `json:"total_peti,omitempty"` + Week *int `json:"week,omitempty"` + ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"` } type SalesOrdersListDTO struct { @@ -29,7 +33,7 @@ type SalesOrdersListDTO struct { // === Mapper Functions === -func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { +func ToMarketingProductDTO(e entity.MarketingProduct, marketingType string) MarketingProductDTO { var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO if e.ProductWarehouse.Id != 0 { @@ -37,21 +41,32 @@ func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { productWarehouse = &mapped } + // Calculate total_peti only for TELUR marketing type + var totalPeti *float64 + if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 { + calculated := e.TotalWeight / *e.WeightPerConvertion + totalPeti = &calculated + } + return MarketingProductDTO{ - Id: e.Id, - Qty: e.Qty, - UnitPrice: e.UnitPrice, - AvgWeight: e.AvgWeight, - TotalWeight: e.TotalWeight, - TotalPrice: e.TotalPrice, - ProductWarehouse: productWarehouse, + Id: e.Id, + Qty: e.Qty, + UnitPrice: e.UnitPrice, + AvgWeight: e.AvgWeight, + TotalWeight: e.TotalWeight, + TotalPrice: e.TotalPrice, + ConvertionUnit: e.ConvertionUnit, + WeightPerConvertion: e.WeightPerConvertion, + TotalPeti: totalPeti, + Week: e.Week, + ProductWarehouse: productWarehouse, } } func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO { products := make([]MarketingProductDTO, len(e.Products)) for i, p := range e.Products { - products[i] = ToMarketingProductDTO(p) + products[i] = ToMarketingProductDTO(p, e.MarketingType) } return SalesOrdersListDTO{ @@ -68,7 +83,7 @@ func ToSalesOrdersListDTOFromMarketing(e entity.Marketing) SalesOrdersListDTO { if len(e.Products) > 0 { salesOrder = make([]MarketingProductDTO, len(e.Products)) for i, product := range e.Products { - salesOrder[i] = ToMarketingProductDTO(product) + salesOrder[i] = ToMarketingProductDTO(product, e.MarketingType) } } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 268a81eb..493f689f 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -65,6 +65,7 @@ func (s deliveryOrdersService) withRelations(db *gorm.DB) *gorm.DB { Preload("Customer"). Preload("SalesPerson"). Preload("Products.ProductWarehouse.Product"). + Preload("Products.ProductWarehouse.Product.Uom"). Preload("Products.ProductWarehouse.Warehouse"). Preload("Products.DeliveryProduct") } @@ -111,6 +112,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO Preload("Customer"). Preload("SalesPerson"). Preload("Products.ProductWarehouse.Product"). + Preload("Products.ProductWarehouse.Product.Uom"). Preload("Products.ProductWarehouse.Warehouse"). Preload("Products.DeliveryProduct") diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index a64caa9f..ffc53d79 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -69,6 +69,7 @@ func (s salesOrdersService) withRelations(db *gorm.DB) *gorm.DB { Preload("Customer"). Preload("SalesPerson"). Preload("Products.ProductWarehouse.Product.Flags"). + Preload("Products.ProductWarehouse.Product.Uom"). Preload("Products.ProductWarehouse.Warehouse") } From 14cc7ef2ae8b593cf36a6cd422821ee32233cfeb Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 4 Feb 2026 13:31:20 +0700 Subject: [PATCH 24/34] [FEAT/BE] Add field purchase response get all --- .../common/service/common.fifo.service.go | 109 ++++++++++++------ .../modules/purchases/dto/purchase.dto.go | 64 +++++++++- 2 files changed, 131 insertions(+), 42 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 190bf819..fd1812fb 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -743,8 +743,6 @@ func (s *fifoService) releaseUsagePortion( if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID { continue } - fmt.Printf("WARN[FIFO] ALLOC WAREHOUSE MISMATCH usable_key=%s usable_id=%d alloc_id=%d expected_pw=%d actual_pw=%d\n", - usableKey.String(), usableID, alloc.Id, expectedWarehouseID, alloc.ProductWarehouseId) if err := tx.Model(&entities.StockAllocation{}). Where("id = ?", alloc.Id). Update("product_warehouse_id", expectedWarehouseID).Error; err != nil { @@ -848,41 +846,80 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p cfg.Columns.CreatedAt, ) - var rows []struct { - ID uint - Pending float64 - CreatedAt time.Time - } - - query := tx.Table(cfg.Table). - Select(selectStmt). - Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). - Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). - Limit(s.pendingBatchPerUsable) - - if cfg.Scope != nil { - query = cfg.Scope(query) - } - - for _, order := range s.orderClauses(cfg.OrderBy) { - query = query.Order(order) - } - - if err := query.Find(&rows).Error; err != nil { - return nil, err - } - - for _, row := range rows { - if row.Pending <= 0 { - continue + if cfg.Columns.CreatedAt == cfg.Columns.ID { + var rows []struct { + ID uint + Pending float64 + CreatedAt int64 + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). + Limit(s.pendingBatchPerUsable) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: time.Unix(0, row.CreatedAt), + }) + } + } else { + var rows []struct { + ID uint + Pending float64 + CreatedAt time.Time + } + + query := tx.Table(cfg.Table). + Select(selectStmt). + Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). + Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). + Limit(s.pendingBatchPerUsable) + + if cfg.Scope != nil { + query = cfg.Scope(query) + } + + for _, order := range s.orderClauses(cfg.OrderBy) { + query = query.Order(order) + } + + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: row.CreatedAt, + }) } - candidates = append(candidates, pendingCandidate{ - UsableKey: key, - Config: cfg, - UsableID: row.ID, - Pending: row.Pending, - CreatedAt: row.CreatedAt, - }) } } diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index 1956729c..444c41f0 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -1,6 +1,7 @@ package dto import ( + "strings" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -24,12 +25,17 @@ type PurchaseRelationDTO struct { type PurchaseListDTO struct { PurchaseRelationDTO - Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - DueDate *time.Time `json:"due_date"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` + DueDate *time.Time `json:"due_date"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + RequesterName string `json:"requester_name"` + PoExpedition []string `json:"po_expedition"` + Products []productDTO.ProductRelationDTO `json:"products"` + Location *locationDTO.LocationRelationDTO `json:"location"` + Area *areaDTO.AreaRelationDTO `json:"area"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } type PurchaseDetailDTO struct { @@ -146,6 +152,10 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { mapped := userDTO.ToUserRelationDTO(p.CreatedUser) createdUser = &mapped } + requesterName := "" + if createdUser != nil { + requesterName = createdUser.Name + } var latestApproval *approvalDTO.ApprovalRelationDTO if p.LatestApproval != nil && p.LatestApproval.Id != 0 { @@ -153,11 +163,53 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { latestApproval = &mapped } + var ( + poExpedition []string + location *locationDTO.LocationRelationDTO + area *areaDTO.AreaRelationDTO + ) + productMap := make(map[uint]productDTO.ProductRelationDTO) + expeditionRefSet := make(map[string]struct{}) + for i := range p.Items { + item := p.Items[i] + if item.Product != nil && item.Product.Id != 0 { + if _, exists := productMap[item.Product.Id]; !exists { + productMap[item.Product.Id] = productDTO.ToProductRelationDTO(*item.Product) + } + } + if item.ExpenseNonstock != nil && item.ExpenseNonstock.Expense != nil { + ref := strings.TrimSpace(item.ExpenseNonstock.Expense.ReferenceNumber) + if ref != "" { + if _, exists := expeditionRefSet[ref]; !exists { + expeditionRefSet[ref] = struct{}{} + poExpedition = append(poExpedition, ref) + } + } + } + if location == nil && item.Warehouse != nil && item.Warehouse.Location != nil && item.Warehouse.Location.Id != 0 { + loc := locationDTO.ToLocationRelationDTO(*item.Warehouse.Location) + location = &loc + } + if area == nil && item.Warehouse != nil && item.Warehouse.Area.Id != 0 { + ar := areaDTO.ToAreaRelationDTO(item.Warehouse.Area) + area = &ar + } + } + products := make([]productDTO.ProductRelationDTO, 0, len(productMap)) + for _, prod := range productMap { + products = append(products, prod) + } + return PurchaseListDTO{ PurchaseRelationDTO: ToPurchaseRelationDTO(&p), Supplier: supplier, DueDate: p.DueDate, CreatedUser: createdUser, + RequesterName: requesterName, + PoExpedition: poExpedition, + Products: products, + Location: location, + Area: area, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, LatestApproval: latestApproval, From 114f1a7c24ed7bf396ae01f0f461d923643d6450 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 4 Feb 2026 13:51:55 +0700 Subject: [PATCH 25/34] fix hasil produksi deplesi std dan filter recording approved --- .../production_result.repository.go | 21 ++++++++++++++++++- .../repports/services/repport.service.go | 3 +++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/modules/repports/repositories/production_result.repository.go b/internal/modules/repports/repositories/production_result.repository.go index a8eccb91..a708be9e 100644 --- a/internal/modules/repports/repositories/production_result.repository.go +++ b/internal/modules/repports/repositories/production_result.repository.go @@ -31,9 +31,25 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang( return []entity.Recording{}, 0, nil } + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action, a.step_number"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + countQuery := r.db.WithContext(ctx). Model(&entity.Recording{}). - Where("project_flock_kandangs_id = ?", projectFlockKandangID) + Joins("JOIN (?) AS la ON la.approvable_id = recordings.id", latestApproval). + Where("project_flock_kandangs_id = ?", projectFlockKandangID). + Where("la.step_number = ?", utils.RecordingStepDisetujui). + Where("la.action = ?", string(entity.ApprovalActionApproved)) var total int64 if err := countQuery.Count(&total).Error; err != nil { @@ -59,7 +75,10 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang( dataQuery := r.db.WithContext(ctx). Model(&entity.Recording{}). + Joins("JOIN (?) AS la ON la.approvable_id = recordings.id", latestApproval). Where("project_flock_kandangs_id = ?", projectFlockKandangID). + Where("la.step_number = ?", utils.RecordingStepDisetujui). + Where("la.action = ?", string(entity.ApprovalActionApproved)). Preload("Eggs", func(db *gorm.DB) *gorm.DB { return db.Select("recording_eggs.*, f.name AS product_flag_name"). Joins("LEFT JOIN product_warehouses pw ON pw.id = recording_eggs.product_warehouse_id"). diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index d45cba62..db9fe3f1 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -398,6 +398,9 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. if detail != nil && detail.TargetMeanBw != nil { weeklyResults[i].StdBw = *detail.TargetMeanBw } + if detail != nil { + weeklyResults[i].DepStd = valueOrZero(detail.MaxDepletion) + } } } From 1d9597636013ef7dad7b2355fed641df9001fcea Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 4 Feb 2026 14:47:56 +0700 Subject: [PATCH 26/34] FEAT[BE] :add marketing type field to delivery and sales order DTOs, enhance validation and service logic for consistent marketing type handling --- .../marketing/dto/deliveryorder.dto.go | 5 +- .../modules/marketing/dto/salesorder.dto.go | 5 +- .../marketing/services/salesorder.service.go | 65 +++++++++++++------ .../validations/salesorder.validation.go | 3 +- 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index 451856c2..bd4b2a0b 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -2,6 +2,7 @@ package dto import ( "fmt" + "math" "sort" "time" @@ -79,6 +80,7 @@ type DeliveryMarketingProductDTO struct { Id uint `json:"id"` MarketingId uint `json:"marketing_id"` ProductWarehouseId uint `json:"product_warehouse_id"` + MarketingType string `json:"marketing_type"` Qty float64 `json:"qty"` UnitPrice float64 `json:"unit_price"` AvgWeight float64 `json:"avg_weight"` @@ -111,7 +113,7 @@ func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType stri // Calculate total_peti only for TELUR marketing type var totalPeti *float64 if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 { - calculated := e.TotalWeight / *e.WeightPerConvertion + calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion) totalPeti = &calculated } @@ -119,6 +121,7 @@ func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType stri Id: e.Id, MarketingId: e.MarketingId, ProductWarehouseId: e.ProductWarehouseId, + MarketingType: marketingType, Qty: e.Qty, UnitPrice: e.UnitPrice, AvgWeight: e.AvgWeight, diff --git a/internal/modules/marketing/dto/salesorder.dto.go b/internal/modules/marketing/dto/salesorder.dto.go index 866fe268..11479036 100644 --- a/internal/modules/marketing/dto/salesorder.dto.go +++ b/internal/modules/marketing/dto/salesorder.dto.go @@ -1,6 +1,7 @@ package dto import ( + "math" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -11,6 +12,7 @@ import ( type MarketingProductDTO struct { Id uint `json:"id"` + MarketingType string `json:"marketing_type"` Qty float64 `json:"qty"` UnitPrice float64 `json:"unit_price"` AvgWeight float64 `json:"avg_weight"` @@ -44,12 +46,13 @@ func ToMarketingProductDTO(e entity.MarketingProduct, marketingType string) Mark // Calculate total_peti only for TELUR marketing type var totalPeti *float64 if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 { - calculated := e.TotalWeight / *e.WeightPerConvertion + calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion) totalPeti = &calculated } return MarketingProductDTO{ Id: e.Id, + MarketingType: marketingType, Qty: e.Qty, UnitPrice: e.UnitPrice, AvgWeight: e.AvgWeight, diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index ffc53d79..58901794 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -104,8 +104,23 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e return nil, err } - if !utils.IsValidMarketingType(req.MarketingType) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM_PULLET") + // Validasi semua product harus punya marketing_type yang sama + if len(req.MarketingProducts) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "marketing_products is required") + } + + firstMarketingType := req.MarketingProducts[0].MarketingType + if !utils.IsValidMarketingType(firstMarketingType) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Tipe penjualan tidak valid") + } + + for i, item := range req.MarketingProducts { + if !utils.IsValidMarketingType(item.MarketingType) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tipe penjualan tidak valid pada produk ke-%d", i+1)) + } + if item.MarketingType != firstMarketingType { + return nil, fiber.NewError(fiber.StatusBadRequest, "Semua produk harus memiliki tipe penjualan yang sama") + } } actorID, err := m.ActorIDFromContext(c) @@ -120,11 +135,11 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } for _, item := range req.MarketingProducts { - if req.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "avg_weight is required for non-TRADING marketing type") + if item.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Berat rata-rata harus diisi") } if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid convertion_unit. Must be one of: PETI, KG") + return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid") } if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { return nil, err @@ -160,7 +175,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e SoDate: soDate, SalesPersonId: req.SalesPersonId, Notes: req.Notes, - MarketingType: req.MarketingType, + MarketingType: firstMarketingType, CreatedBy: actorID, } if err := marketingRepoTx.CreateOne(c.Context(), marketing, nil); err != nil { @@ -173,7 +188,7 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e if product.ProductWarehouseId != 0 { pwIDs = append(pwIDs, product.ProductWarehouseId) } - if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, marketing.MarketingType, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { + if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product.MarketingType, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") } } @@ -218,8 +233,21 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return nil, err } - if req.MarketingType != "" && !utils.IsValidMarketingType(req.MarketingType) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing_type. Must be one of: AYAM, TELUR, TRADING, AYAM_PULLET") + // Validasi semua product harus punya marketing_type yang sama + if len(req.MarketingProducts) > 0 { + firstMarketingType := req.MarketingProducts[0].MarketingType + if !utils.IsValidMarketingType(firstMarketingType) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Tipe penjualan tidak valid") + } + + for i, item := range req.MarketingProducts { + if !utils.IsValidMarketingType(item.MarketingType) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tipe penjualan tidak valid pada produk ke-%d", i+1)) + } + if item.MarketingType != firstMarketingType { + return nil, fiber.NewError(fiber.StatusBadRequest, "Semua produk harus memiliki tipe penjualan yang sama") + } + } } if err := m.EnsureMarketingAccess(c, s.MarketingRepo.DB(), id); err != nil { @@ -249,11 +277,11 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if len(req.MarketingProducts) > 0 { for _, item := range req.MarketingProducts { - if req.MarketingType != "" && req.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "avg_weight is required for non-TRADING marketing type") + if item.MarketingType != string(utils.MarketingTypeTrading) && item.AvgWeight == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Berat rata-rata harus diisi") } if item.ConvertionUnit != nil && !utils.IsValidConvertionUnit(*item.ConvertionUnit) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid convertion_unit. Must be one of: PETI, KG") + return nil, fiber.NewError(fiber.StatusBadRequest, "Unit konversi tidak valid") } if err := m.EnsureProductWarehouseAccess(c, s.MarketingRepo.DB(), item.ProductWarehouseId); err != nil { return nil, err @@ -302,8 +330,8 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if req.Notes != "" { updateBody["notes"] = req.Notes } - if req.MarketingType != "" { - updateBody["marketing_type"] = req.MarketingType + if len(req.MarketingProducts) > 0 { + updateBody["marketing_type"] = req.MarketingProducts[0].MarketingType } if len(updateBody) > 0 { @@ -330,15 +358,10 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u reqByPW[rp.ProductWarehouseId] = rp } - marketing, err := marketingRepoTx.GetByID(c.Context(), id, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch marketing") - } - for _, rp := range req.MarketingProducts { if old, ok := oldByPW[rp.ProductWarehouseId]; ok { - totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week) + totalWeight, totalPrice := s.calculatePriceByMarketingType(rp.MarketingType, rp.Qty, rp.AvgWeight, rp.UnitPrice, rp.Week) deliveryProduct, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -397,7 +420,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } } else { - if err := s.createMarketingProductWithDelivery(c.Context(), id, marketing.MarketingType, rp, marketingProductRepoTx, invDeliveryRepoTx); err != nil { + if err := s.createMarketingProductWithDelivery(c.Context(), id, rp.MarketingType, rp, marketingProductRepoTx, invDeliveryRepoTx); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") } } diff --git a/internal/modules/marketing/validations/salesorder.validation.go b/internal/modules/marketing/validations/salesorder.validation.go index bf38417f..6d6b80b6 100644 --- a/internal/modules/marketing/validations/salesorder.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -5,11 +5,11 @@ type Create struct { SalesPersonId uint `json:"sales_person_id" validate:"required,gt=0"` Date string `json:"date" validate:"required,datetime=2006-01-02"` Notes string `json:"notes" validate:"omitempty,max=500"` - MarketingType string `json:"marketing_type" validate:"required,min=1,max=50"` MarketingProducts []CreateMarketingProduct `json:"marketing_products" validate:"required,min=1,dive"` } type CreateMarketingProduct struct { + MarketingType string `json:"marketing_type" validate:"required,min=1,max=50"` VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` ConvertionUnit *string `json:"convertion_unit" validate:"omitempty,min=1,max=20"` WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"` @@ -25,7 +25,6 @@ type Update struct { SalesPersonId uint `json:"sales_person_id" validate:"omitempty,gt=0"` Date string `json:"date" validate:"omitempty,datetime=2006-01-02"` Notes string `json:"notes" validate:"omitempty,max=500"` - MarketingType string `json:"marketing_type" validate:"omitempty,min=1,max=50"` MarketingProducts []CreateMarketingProduct `json:"marketing_products" validate:"omitempty,min=1,dive"` } From 1f10e962885079103da0dabd8d172dad3b834e16 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 4 Feb 2026 15:17:05 +0700 Subject: [PATCH 27/34] fix query field source_warehouse --- internal/modules/closings/repositories/closing.repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 4f699086..a4db4694 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -566,8 +566,8 @@ SELECT FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, - COALESCE(w.name, '') AS source_warehouse, - '-' AS destination_warehouse, + '-' AS source_warehouse, + COALESCE(w.name, '') AS destination_warehouse, '' AS destination, COALESCE(ast.total_qty, 0) AS quantity, u.id AS unit_id, From aa1fd1c35b6485f6a1bf944d9384236e6cab3e73 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 5 Feb 2026 09:57:38 +0700 Subject: [PATCH 28/34] FEAT[BE] :update price calculation in sales order service for accurate rounding, add new conversion unit for quantity --- .../modules/marketing/services/salesorder.service.go | 11 ++++++----- internal/utils/constant.go | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 58901794..eb2e4f5b 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strings" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -752,13 +753,13 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) { if marketingType == string(utils.MarketingTypeTrading) { totalWeight = 0 - totalPrice = qty * unitPrice + totalPrice = math.Round(qty*unitPrice*100) / 100 } else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 { - totalWeight = qty * avgWeight - totalPrice = unitPrice * float64(*week) * qty + totalWeight = math.Round(qty*avgWeight*100) / 100 + totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100 } else { - totalWeight = qty * avgWeight - totalPrice = totalWeight * unitPrice + totalWeight = math.Round(qty*avgWeight*100) / 100 + totalPrice = math.Round(totalWeight*unitPrice*100) / 100 } return totalWeight, totalPrice } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 1de04fa3..27d1ec3e 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -234,6 +234,7 @@ type ConvertionUnit string const ( ConvertionUnitPeti ConvertionUnit = "PETI" ConvertionUnitKG ConvertionUnit = "KG" + ConvertionUnitQty ConvertionUnit = "QTY" ) // ------------------------------------------------------------------- @@ -643,7 +644,7 @@ func IsValidMarketingType(v string) bool { func IsValidConvertionUnit(v string) bool { switch ConvertionUnit(v) { - case ConvertionUnitPeti, ConvertionUnitKG: + case ConvertionUnitPeti, ConvertionUnitKG, ConvertionUnitQty: return true } return false From d41f1b9495165702fc5db454ed09b143a2e9113b Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Thu, 5 Feb 2026 10:26:44 +0700 Subject: [PATCH 29/34] fix(BE): multiple filter, all search --- .../controllers/transaction.controller.go | 83 +++++++++++++------ .../services/transaction.service.go | 53 ++++++++---- .../validations/transaction.validation.go | 24 +++--- 3 files changed, 110 insertions(+), 50 deletions(-) diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go index 5c25cbcd..228feeaa 100644 --- a/internal/modules/finance/transactions/controllers/transaction.controller.go +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -24,46 +24,81 @@ func NewTransactionController(transactionService service.TransactionService) *Tr } func (u *TransactionController) GetAll(c *fiber.Ctx) error { - parseOptionalUint := func(key string) (*uint, error) { + parseUintListParam := func(key string) ([]uint, error) { raw := strings.TrimSpace(c.Query(key, "")) if raw == "" { return nil, nil } - parsed, err := strconv.ParseUint(raw, 10, 64) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid "+key) + parts := strings.Split(raw, ",") + ids := make([]uint, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + parsed, err := strconv.ParseUint(trimmed, 10, 64) + if err != nil { + return nil, err + } + if parsed == 0 { + continue + } + ids = append(ids, uint(parsed)) } - if parsed == 0 { + if len(ids) == 0 { return nil, nil } - value := uint(parsed) - return &value, nil + return ids, nil } - bankId, err := parseOptionalUint("bank_id") - if err != nil { - return err + parseStringListParam := func(key string) ([]string, error) { + raw := strings.TrimSpace(c.Query(key, "")) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + return nil, strconv.ErrSyntax + } + values = append(values, trimmed) + } + if len(values) == 0 { + return nil, nil + } + return values, nil } - customerId, err := parseOptionalUint("customer_id") + + bankIDs, err := parseUintListParam("bank_ids") if err != nil { - return err + return fiber.NewError(fiber.StatusBadRequest, "Invalid bank_ids") } - supplierId, err := parseOptionalUint("supplier_id") + customerIDs, err := parseUintListParam("customer_ids") if err != nil { - return err + return fiber.NewError(fiber.StatusBadRequest, "Invalid customer_ids") + } + supplierIDs, err := parseUintListParam("supplier_ids") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid supplier_ids") + } + transactionTypes, err := parseStringListParam("transaction_types") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_types") } query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), - TransactionType: c.Query("transaction_type", ""), - BankId: bankId, - CustomerId: customerId, - SupplierId: supplierId, - SortDate: c.Query("sort_date", ""), - StartDate: c.Query("start_date", ""), - EndDate: c.Query("end_date", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + TransactionTypes: transactionTypes, + BankIDs: bankIDs, + CustomerIDs: customerIDs, + SupplierIDs: supplierIDs, + SortDate: c.Query("sort_date", ""), + StartDate: c.Query("start_date", ""), + EndDate: c.Query("end_date", ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go index f422320f..4526b817 100644 --- a/internal/modules/finance/transactions/services/transaction.service.go +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -74,33 +74,58 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en if params.Search != "" { like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" + db = db.Joins( + "LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL", + string(utils.PaymentPartyCustomer), + ).Joins( + "LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL", + string(utils.PaymentPartySupplier), + ).Joins( + "LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL", + ) db = db.Where( `LOWER(payment_code) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR - LOWER(COALESCE(notes, '')) LIKE ?`, - like, like, like, like, + LOWER(COALESCE(notes, '')) LIKE ? OR + LOWER(COALESCE(customers.name, '')) LIKE ? OR + LOWER(COALESCE(suppliers.name, '')) LIKE ? OR + LOWER(COALESCE(banks.name, '')) LIKE ?`, + like, like, like, like, like, like, like, ) } - if strings.TrimSpace(params.TransactionType) != "" { - db = db.Where("transaction_type = ?", strings.ToUpper(strings.TrimSpace(params.TransactionType))) + if len(params.TransactionTypes) > 0 { + types := make([]string, 0, len(params.TransactionTypes)) + for _, transactionType := range params.TransactionTypes { + normalized := strings.ToUpper(strings.TrimSpace(transactionType)) + if normalized == "" { + continue + } + types = append(types, normalized) + } + if len(types) > 0 { + db = db.Where("transaction_type IN ?", types) + } } - if params.BankId != nil { - db = db.Where("bank_id = ?", *params.BankId) + if len(params.BankIDs) > 0 { + db = db.Where("bank_id IN ?", params.BankIDs) } - if params.CustomerId != nil && params.SupplierId != nil { + customerIDs := params.CustomerIDs + supplierIDs := params.SupplierIDs + + if len(customerIDs) > 0 && len(supplierIDs) > 0 { db = db.Where( - "(party_type = ? AND party_id = ?) OR (party_type = ? AND party_id = ?)", - string(utils.PaymentPartyCustomer), *params.CustomerId, - string(utils.PaymentPartySupplier), *params.SupplierId, + "(party_type = ? AND party_id IN ?) OR (party_type = ? AND party_id IN ?)", + string(utils.PaymentPartyCustomer), customerIDs, + string(utils.PaymentPartySupplier), supplierIDs, ) - } else if params.CustomerId != nil { - db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartyCustomer), *params.CustomerId) - } else if params.SupplierId != nil { - db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartySupplier), *params.SupplierId) + } else if len(customerIDs) > 0 { + db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartyCustomer), customerIDs) + } else if len(supplierIDs) > 0 { + db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartySupplier), supplierIDs) } if startDate != nil { diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go index f367dda1..7a71cb51 100644 --- a/internal/modules/finance/transactions/validations/transaction.validation.go +++ b/internal/modules/finance/transactions/validations/transaction.validation.go @@ -1,22 +1,22 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` - TransactionType string `query:"transaction_type" validate:"omitempty,max=50"` - BankId *uint `query:"bank_id" validate:"omitempty,number,gt=0"` - CustomerId *uint `query:"customer_id" validate:"omitempty,number,gt=0"` - SupplierId *uint `query:"supplier_id" validate:"omitempty,number,gt=0"` - SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"` - StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` - EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"` + BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"` + CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` + SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,gt=0"` + SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } From 248ca1d5228ffa480eb6c04495288759a6b7f2c5 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 5 Feb 2026 10:27:00 +0700 Subject: [PATCH 30/34] adjust query value notes --- internal/modules/closings/repositories/closing.repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index a4db4694..5fd6b7e9 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -522,7 +522,7 @@ SELECT std.usage_qty AS quantity, u.id AS unit_id, u.name AS unit, - 'Stock Refill' AS notes + st.reason AS notes FROM stock_transfer_details std JOIN stock_transfers st ON st.id = std.stock_transfer_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id @@ -622,7 +622,7 @@ SELECT std.usage_qty AS quantity, u.id AS unit_id, u.name AS unit, - 'Transfer to other unit' AS notes + st.reason AS notes FROM stock_transfer_details std JOIN stock_transfers st ON st.id = std.stock_transfer_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id From fc157dfd79f43283f65c1dcd773c20c18f709feb Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Thu, 5 Feb 2026 14:16:02 +0700 Subject: [PATCH 31/34] fix(BE): filter by transaction or realization in report customer payment --- .../controllers/repport.controller.go | 1 + .../repports/services/repport.service.go | 20 ++++++++++++++++--- .../validations/repport.validation.go | 1 + internal/utils/constant.go | 9 +++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 9becdf87..aff0a718 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -324,6 +324,7 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { Page: ctx.QueryInt("page", 1), Limit: ctx.QueryInt("limit", 10), CustomerIDs: customerIDs, + FilterBy: strings.ToUpper(ctx.Query("filter_by", "")), StartDate: ctx.Query("start_date", ""), EndDate: ctx.Query("end_date", ""), } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 713fe6a4..38fdd74b 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -582,6 +582,11 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID return dto.CustomerPaymentReportItem{}, err } + filterBy := strings.ToUpper(strings.TrimSpace(params.FilterBy)) + if filterBy == "" { + filterBy = utils.CustomerPaymentFilterByTransDate + } + var startDate, endDate *time.Time if params.StartDate != "" { parsed, err := time.ParseInLocation("2006-01-02", params.StartDate, location) @@ -600,11 +605,20 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID } for _, row := range rows { - transDate := row.TransDate.In(location) - if startDate != nil && transDate.Before(*startDate) { + var compareDate time.Time + if filterBy == utils.CustomerPaymentFilterByRealizationDate { + if row.DeliveryDate == nil { + continue + } + compareDate = row.DeliveryDate.In(location) + } else { + compareDate = row.TransDate.In(location) + } + + if startDate != nil && compareDate.Before(*startDate) { continue } - if endDate != nil && transDate.After(*endDate) { + if endDate != nil && compareDate.After(*endDate) { continue } filteredRows = append(filteredRows, row) diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 37c581d9..97ea60fa 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -85,6 +85,7 @@ type CustomerPaymentQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=TRANS_DATE REALIZATION_DATE"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index d395ad3c..2b91f579 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -161,6 +161,15 @@ const ( ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP" ) +// ------------------------------------------------------------------- +// Filter Customer Payment +// ------------------------------------------------------------------- + +const ( + CustomerPaymentFilterByTransDate = "TRANS_DATE" + CustomerPaymentFilterByRealizationDate = "REALIZATION_DATE" +) + // ------------------------------------------------------------------- // Payment Method // ------------------------------------------------------------------- From 58aed76bbb9666bd4cefef89e6ce33d167b0d47a Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 6 Feb 2026 10:58:54 +0700 Subject: [PATCH 32/34] add query for feed supplier --- .../hpp_per_kandang.repository.go | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index eeb09e92..e13d3f17 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -6,6 +6,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -208,6 +209,75 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } } + feedRows := make([]struct { + ProjectFlockKandangID uint + FeedCost float64 + SupplierID *uint + SupplierName *string + SupplierAlias *string + }, 0) + + feedQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + r.project_flock_kandangs_id AS project_flock_kandang_id, + s.id AS supplier_id, + s.name AS supplier_name, + s.alias AS supplier_alias`). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). + Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("f.name = ?", utils.FlagPakan). + Group("r.project_flock_kandangs_id, s.id, s.name, s.alias") + + if err := feedQuery.Scan(&feedRows).Error; err != nil { + return nil, nil, err + } + + feedSuppliers := make([]HppPerKandangSupplierRow, 0) + feedSeen := make(map[uint]map[uint]bool) + for _, feed := range feedRows { + entry, ok := costMap[feed.ProjectFlockKandangID] + if !ok { + rows = append(rows, HppPerKandangCostRow{ + ProjectFlockKandangID: feed.ProjectFlockKandangID, + }) + entry = &rows[len(rows)-1] + costMap[feed.ProjectFlockKandangID] = entry + } + entry.FeedCost += feed.FeedCost + if feed.SupplierID != nil { + if feedSeen[feed.ProjectFlockKandangID] == nil { + feedSeen[feed.ProjectFlockKandangID] = make(map[uint]bool) + } + if !feedSeen[feed.ProjectFlockKandangID][*feed.SupplierID] { + feedSeen[feed.ProjectFlockKandangID][*feed.SupplierID] = true + supplierName := "" + if feed.SupplierName != nil { + supplierName = *feed.SupplierName + } + supplierAlias := "" + if feed.SupplierAlias != nil { + supplierAlias = *feed.SupplierAlias + } + feedSuppliers = append(feedSuppliers, HppPerKandangSupplierRow{ + ProjectFlockKandangID: feed.ProjectFlockKandangID, + SupplierID: *feed.SupplierID, + SupplierName: supplierName, + SupplierAlias: supplierAlias, + Category: "FEED", + }) + } + } + } + + docSuppliers = append(docSuppliers, feedSuppliers...) return rows, docSuppliers, nil } From 18672f541e06e6001afb4dfb3037f4c7b21e5aa6 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Fri, 6 Feb 2026 11:27:48 +0700 Subject: [PATCH 33/34] fix(BE): add payment method search in module finance --- .../finance/transactions/services/transaction.service.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go index 4526b817..c72ff2a3 100644 --- a/internal/modules/finance/transactions/services/transaction.service.go +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -86,12 +86,13 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en db = db.Where( `LOWER(payment_code) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR + LOWER(COALESCE(payment_method, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR LOWER(COALESCE(notes, '')) LIKE ? OR LOWER(COALESCE(customers.name, '')) LIKE ? OR LOWER(COALESCE(suppliers.name, '')) LIKE ? OR LOWER(COALESCE(banks.name, '')) LIKE ?`, - like, like, like, like, like, like, like, + like, like, like, like, like, like, like, like, ) } From 1a56b37e4ef56b296fa0c4cefce1d157187b9c99 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Fri, 6 Feb 2026 23:36:20 +0700 Subject: [PATCH 34/34] Create job for MR --- .gitlab-ci.yml | 16 ++++++++++++++- ci/development.yml | 11 +++++----- ci/merge_request.yml | 48 ++++++++++++++++++++++++++++++++++++++++++++ ci/production.yml | 39 +++++++++++------------------------ ci/staging.yml | 35 ++++++++++++-------------------- 5 files changed, 94 insertions(+), 55 deletions(-) create mode 100644 ci/merge_request.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6a4778a3..2417298f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,15 +1,29 @@ workflow: rules: + # MR pipeline - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: always + + # Push pipeline hanya untuk env branch - if: '$CI_COMMIT_BRANCH == "development"' + when: always - if: '$CI_COMMIT_BRANCH == "staging"' + when: always - if: '$CI_COMMIT_BRANCH == "production"' + when: always + + # Selain itu jangan buat pipeline - when: never include: - - local: "ci/development.yml" + # khusus MR (notif) + - local: "ci/merge_request.yml" rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + + # khusus push ke branch env + - local: "ci/development.yml" + rules: - if: '$CI_COMMIT_BRANCH == "development"' - local: "ci/staging.yml" diff --git a/ci/development.yml b/ci/development.yml index 43d574b9..7b4733b5 100644 --- a/ci/development.yml +++ b/ci/development.yml @@ -4,9 +4,14 @@ stages: deploy-dev: stage: deploy image: alpine:3.20 + + rules: + - if: '$CI_COMMIT_BRANCH == "development"' + when: on_success + - when: never + variables: DEPLOY_APP: "LTI-MBUGROUP" - # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga GIT_SUBMODULE_STRATEGY: recursive GIT_DEPTH: "1" @@ -27,7 +32,6 @@ deploy-dev: script: - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - - > if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " set -e @@ -83,8 +87,5 @@ deploy-dev: curl -sS -H "Content-Type: application/json" \ -d @payload.json "$DISCORD_WEBHOOK_URL"; - only: - - development - environment: name: development diff --git a/ci/merge_request.yml b/ci/merge_request.yml new file mode 100644 index 00000000..3c43d027 --- /dev/null +++ b/ci/merge_request.yml @@ -0,0 +1,48 @@ +stages: + - notify + +notify_discord_on_mr_request_main_dev: + stage: notify + image: alpine:3.20 + rules: + # hanya MR yang target ke main atau development + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")' + when: on_success + - when: never + + script: + - apk add --no-cache curl jq coreutils + - | + TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')" + TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + TITLE="${CI_MERGE_REQUEST_TITLE}" + IID="!${CI_MERGE_REQUEST_IID}" + USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})" + PROJECT_PATH="${CI_PROJECT_PATH}" + USERNAME="${GITLAB_USER_LOGIN}" + MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}" + + DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \ + "$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")" + + payload=$(jq -n \ + --arg desc "$DESC" \ + --arg project "$PROJECT_PATH" \ + --arg timeiso "$TIME_ISO" \ + --arg mrurl "$MR_URL" \ + '{ + "username": "Mock-api - Merge Requests", + "embeds": [ + { + "description": ($desc + "\n" + $mrurl), + "color": 15105570, + "footer": { "text": $project }, + "timestamp": $timeiso + } + ] + }') + + curl -sS -H "Content-Type: application/json" \ + -d "$payload" \ + "$DISCORD_WEBHOOK_URL" diff --git a/ci/production.yml b/ci/production.yml index 48bf64fb..ed1a2d72 100644 --- a/ci/production.yml +++ b/ci/production.yml @@ -8,12 +8,6 @@ default: tags: - self-hosted-prod -workflow: - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - when: always - - when: never - variables: DOCKER_BUILDKIT: "1" @@ -30,7 +24,9 @@ variables: build_production: stage: build rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_COMMIT_BRANCH == "production"' + when: on_success + - when: never script: | set -e docker info @@ -47,14 +43,15 @@ build_production: docker tag "$IMAGE_NAME" "$IMAGE_LATEST" docker push "$IMAGE_LATEST" - # ========================= # MIGRATE (PRODUCTION) # ========================= migrate_production: stage: migrate rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_COMMIT_BRANCH == "production"' + when: on_success + - when: never needs: - job: build_production artifacts: false @@ -66,12 +63,10 @@ migrate_production: test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - # ✅ load env dari server set -a . ./.env set +a - # ✅ validasi test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) @@ -81,21 +76,13 @@ migrate_production: export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" echo "✅ DATABASE_URL=$DATABASE_URL" - # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) - echo "✅ Ensuring postgres & redis running ..." + # NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*) docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true - # ✅ Ambil network key dari compose COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" - echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" - - # ✅ Cari network name yang dipakai docker NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) - echo "✅ Docker network detected: $NETWORK_NAME" - - # ✅ Migrations dari repo (CI workspace) echo "✅ Checking migrations from repo..." ls -lah "$CI_PROJECT_DIR/internal/database/migrations" @@ -111,7 +98,6 @@ migrate_production: echo "$out" - # ✅ Handle no change dengan benar (tidak false-success) if echo "$out" | grep -qi "no change"; then echo "✅ No change (already up to date)" exit 0 @@ -124,17 +110,16 @@ migrate_production: echo "✅ Migration applied successfully" - # ========================= # DEPLOY (AUTO) # ========================= deploy_production: stage: deploy rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_COMMIT_BRANCH == "production"' + when: on_success + - when: never needs: -# - job: migrate_production -# artifacts: false - job: build_production artifacts: false script: | @@ -150,7 +135,6 @@ deploy_production: docker compose -f "$COMPOSE_FILE" up -d --force-recreate docker image prune -f - # ========================= # SEED (MANUAL) # ========================= @@ -159,9 +143,10 @@ seed_production: rules: - if: '$CI_COMMIT_BRANCH == "production"' when: manual + - when: never script: | set -e - cd /opt/deploy/lti + cd "$DEPLOY_DIR" test -f .env || (echo "❌ .env not found" && exit 1) echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" diff --git a/ci/staging.yml b/ci/staging.yml index e3eaabb0..5ac5f2c7 100644 --- a/ci/staging.yml +++ b/ci/staging.yml @@ -8,12 +8,6 @@ default: tags: - self-hosted-stg -workflow: - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - when: always - - when: never - variables: DOCKER_BUILDKIT: "1" @@ -30,7 +24,9 @@ variables: build_staging: stage: build rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "staging"' + when: on_success + - when: never script: | set -e docker info @@ -47,14 +43,15 @@ build_staging: docker tag "$IMAGE_NAME" "$IMAGE_LATEST" docker push "$IMAGE_LATEST" - # ========================= # MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "staging"' + when: on_success + - when: never needs: - job: build_staging artifacts: false @@ -66,12 +63,10 @@ migrate_staging: test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - # ✅ load env dari server set -a . ./.env set +a - # ✅ validasi test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) @@ -81,21 +76,17 @@ migrate_staging: export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" echo "✅ DATABASE_URL=$DATABASE_URL" - # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) echo "✅ Ensuring postgres & redis running ..." docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true - # ✅ Ambil network key dari compose COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" - # ✅ Cari network name yang dipakai docker NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) echo "✅ Docker network detected: $NETWORK_NAME" - # ✅ Migrations dari repo (CI workspace) echo "✅ Checking migrations from repo..." ls -lah "$CI_PROJECT_DIR/internal/database/migrations" @@ -111,7 +102,6 @@ migrate_staging: echo "$out" - # ✅ Handle no change dengan benar (tidak false-success) if echo "$out" | grep -qi "no change"; then echo "✅ No change (already up to date)" exit 0 @@ -124,14 +114,15 @@ migrate_staging: echo "✅ Migration applied successfully" - # ========================= # DEPLOY (AUTO) # ========================= deploy_staging: stage: deploy rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "staging"' + when: on_success + - when: never needs: - job: migrate_staging artifacts: false @@ -150,18 +141,18 @@ deploy_staging: docker compose -f "$COMPOSE_FILE" up -d --force-recreate docker image prune -f - # ========================= # SEED (MANUAL) # ========================= seed_staging: stage: seed rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "staging"' + when: manual + - when: never needs: - job: deploy_staging artifacts: false - when: manual allow_failure: false script: | set -e @@ -170,4 +161,4 @@ seed_staging: test -f .env || (echo "❌ .env not found" && exit 1) docker compose -f "$COMPOSE_FILE" pull seed || true - docker compose -f "$COMPOSE_FILE" run --rm seed% + docker compose -f "$COMPOSE_FILE" run --rm seed